Switching away from a worktree that had a targeted background mount
unmounted BrowserPaneOverlayLayer, pulling the persistent <webview>
slots out of the DOM and killing their guests; the stale viewport cache
then kept rendering into the removed subtree, so the tab stayed blank
forever and reload threw. Keep the overlay mounted for hidden worktrees
(slots park their panes, so this stays cheap) and rebuild cached
viewports whose slot root remounted.
* fix(tasks): cap advertised GitHub pages at the search result window
GitHub's Search API rejects requests past its first-1000-results window
with HTTP 422, but totalPages was derived from the raw total_count, so
the pagination bar advertised pages that could never load and clicks on
them silently did nothing (#11485).
Cap per-repo advertised pages at floor(1000 / perRepoLimit), and when a
page load comes back empty, say so with a toast instead of ignoring the
click — clamping the advertised count only when no fetch threw, so
transient failures don't shrink the bar.
* fix(tasks): key pagination resets on repo selection, not array identity
The repos store installs a fresh array on every repos:changed event, so
the pagination-reset effect fired on background refreshes and bumped the
request generation, silently discarding any in-flight page navigation —
clicking an unloaded page did nothing whenever a repo refresh landed
during the fetch. Key the effect on the stable selection string instead.
* fix(tasks): distinguish end-of-data, window 422s, and failures on empty pages
Adversarial-review round 1 rework:
- fetchWorkItemsNextPage now returns issue-side envelope error types — the
channel the search-window 422 actually travels on (failedCount only
counts thrown repo calls).
- resolveEmptyPageOutcome (unit-tested) maps an empty page to
window-unreachable (clamp + toast), load-failed (toast only; may be
transient), or end-of-data (silently withdraw the speculative page the
count-fallback advertises).
- The work-items fetch effect is keyed on selectedReposKey too — its
unconditional page reset re-fired on every repos:changed array identity,
bouncing the user to page 1 mid-click. The key now includes the resolved
GitHub source context so identity changes still re-dispatch.
- Toasts carry stable ids so repeats replace instead of stack.
- Cap comment documents the conservative PR-scope tail loss; cap tests
pinned at shipped (36 → 27) and dividing (25 → 40) limits.
* fix(tasks): withdraw the speculative page when the failed count is zero
countedTotalPages of 0 comes from a swallowed count failure and routes
totalPages through the fallback, so the clamp must replace it like null.
* fix(tasks): tighten empty-page outcomes after round-2 review
- en.json's loadPageUnreachable carried the pre-reword text, and the
catalog beats the inline default — the two toasts were identical.
- end-of-data clamps only while the count is unknown/failed: the PR list
path swallows its own failures into clean-empty results, and clamping a
real count silently hid healthy pages (worse than the pre-fix no-op).
- A window 422 no longer clamps when a sibling repo's fetch threw.
- The generation effect mirrors every fetch-effect dep that resets page
state, so manual refresh/source switches invalidate in-flight clicks.
- selectedReposKey extracted as buildSelectedReposKey with stability
tests; envelope error types wire-tested through the store.
* fix(tasks): clamp against the committed count, not the click-time closure
Round-3 review: the count promise routinely resolves between click and
response, so deciding the end-of-data clamp from the closure value let a
stale null overwrite a real count. applyEmptyPageClamp now runs inside
the functional updater against the committed value, never raises an
earlier clamp, and a window 422 coinciding with a thrown sibling repo
resolves as load-failed so the toast and the clamp always agree.
* fix(tasks): only an all-window-422 empty page may clamp; harden count merges
Round-4 review: a sibling repo's envelope 403/404 arrives with
failedCount still 0, so the window branch now requires every error to be
the window 422 (non-window validation errors are demoted at the store);
the count resolution mins against an applied clamp instead of
re-advertising withdrawn pages; the generation effect mirrors
taskResumeApplied so its doc claim holds.
* fix(tasks): split the proven window limit from the count slot
Round-5 review: min-ing the count against an applied clamp pinned a
SPECULATIVE end-of-data withdrawal that raced ahead of the count,
permanently collapsing the bar for the generation. Proven window-422
limits now live in provenPageLimit (set once, only lowered, reset per
generation); the count overwrites its own slot unconditionally; and
deriveAdvertisedTotalPages (unit-tested for both arrival orders) caps
the count-or-fallback estimate with the proven limit, floored at the
loaded pages.
* fix(tasks): surface PR-side list failures so they can't read as end-of-data
Round-6 review: PartialWorkItemsResult had no PR error slot, so a
swallowed gh pr list failure reached the renderer as a clean empty page
— and with the count blocked (0) the speculative withdrawal deleted the
pagination bar with no toast and no recovery (a regression vs main's
silent no-op). PR-side errors now ride the envelope (errors.prs),
demoted so they can never join the issue-only window-422 signal;
errorTypes replaces issueErrorTypes; an empty page that a real count
said should exist now toasts instead of looking dead.
* test(tasks): cover the PR-error envelope end-to-end; neutral no-more-results toast
Round-7 review: the two literal gh-utils mocks lacked classifyListPrsError
(a PR-side rejection in those suites would TypeError instead of assert),
and the producer half of the errors.prs contract had no main-side test —
added both, plus a classifier contract test pinning the search-window
phrase the renderer keys on. The refused-clamp toast now reads the
committed count via a synchronous ref mirror instead of the click-time
closure, and says 'No more results' — nothing failed on that branch.
Both toast keys plus the new one are translated in es/ja/ko/zh.
* fix(tasks): preserve final reachable GitHub search page
* Extract GitHub search result window error pattern to shared constant
Extract the 1000-result window detection pattern to a single source of truth so
the classifier and consumer stay synchronized. The pattern is the only signal
separating a permanently unreachable page from a transient validation failure,
so drift or trimming silently demotes window 422s to generic failures and stops
capping the advertised page count (#11485).
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
* Allow clearing all agents from AI Vault session history filter
Add "Select all" / "Clear" buttons so users can quickly isolate one agent without unchecking each box individually. Previously, at least one agent had to remain enabled; now users can filter to zero agents and re-enable selectively.
* Address PR #12128 review feedback
- Make Select all / Clear real DropdownMenuItems so Radix roving focus reaches them by keyboard.
- Rename the zero-agent empty state to a neutral "No agents selected" now that zero agents is a valid filter.
- Use 모두 해제 for the Korean Clear label instead of 지우기 (erase).
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Both hosts carried token-identical copies of the GitHub work-item mutation
wrappers, PR diff mapping, presentation formatters, and four components.
Move them into src/renderer/src/components/github/ so there is one source.
Behavior-neutral: getStateTone, WorkItemStateBadge, PRReviewersPanel,
PRActionsPanel, CommentReplyForm, and the per-host work-item/PR-file caches
stay in place because they genuinely differ between the two hosts.
CommentCodeContext takes loadPRFileContents as a prop so each host keeps its
own private file-contents cache.
Also folds github-issue-comment-helpers.ts into github-user-avatar.tsx and
retargets the textual boundary tests at the new modules.
* feat(updater): add an adhoc release channel for branch builds
Hourly covers main. This covers everything that is not main yet: a
dispatchable macOS build of an unlanded branch, published to
stablyai/orca-adhoc, so the team can run an experimental feature for a
few days instead of reasoning about it from a diff.
Adhoc sits at the bottom of the version order — 'adhoc' < 'hourly' <
'rc' < stable — so no routine check can walk anyone onto somebody's
branch; only an explicit pinned jump reaches one. It gets its own repo
rather than sharing orca-hourly's, because a branch build must not
appear in the list a developer riding main is looking at.
Signed and notarized exactly like hourly, for the same reason: macOS
anchors a notarized app's TCC grants on identifier + team, so an
unnotarized build reads as a new client and silently loses file access
under Documents/Desktop/Downloads.
Tags stamp to the second rather than the minute. Hourly runs under a
concurrency group and cannot overlap itself; adhoc builds are dispatched
on demand, so two people cutting from different branches inside one
minute is ordinary — and a minute-resolution tag would collide and fail
the second build after its whole pack-and-notarize run.
Channel-specific behaviour now derives from one DEDICATED_REPO_CHANNELS
list: repo mapping, macOS-only support, and UpdateSource. The RPC schema
that validates releaseChannelOverride was a hand-copied enum missing the
new channel, which would have rejected the override on its way to the
main process; it reads the predicate now.
* fix(updater): merge the duplicated shared/types import
Co-authored-by: Orca <help@stably.ai>
* fix(ci): default the adhoc build ref to the dispatch branch
The Actions UI puts its own "Use workflow from" branch picker directly
above the ref field, and picking a branch there is what most people read
as "build this". Making the field optional means the obvious action is
also the correct one; naming a branch explicitly still wins, so main's
copy of the workflow runs rather than a stale one on an old branch.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(editor): stop filing crash reports for expected lazy-chunk swaps
RichMarkdownErrorBoundary reported every caught error as a react-error-boundary
crash, including the LazyChunkLoadError sentinel that lazy-with-retry throws
after it has already exhausted its retries and its one guarded reload. That
sentinel means "the chunk hash changed under a running window" (an app update),
which is deliberate graceful degradation, not a crash.
RecoverableRenderErrorBoundary already skips reporting it (#6206); this boundary
was never updated. Crash b860def2 is exactly that path: a lazy_chunk_reload
breadcrumb ("Unexpected token ':'") fires first, then the post-reload attempt
surfaces LazyChunkLoadError and files a report.
The fallback UI is unchanged, so the pane stays usable and offers retry.
* fix(editor): prove the lazy-chunk reload landed before suppressing crash reports
- lazy-with-retry: reload guard stores the requesting document's identity, so a
vetoed reload() no longer reads as "recovery ran" (crash b860def2)
- lazy-with-retry: bound the post-reload suspension so a vetoed navigation
surfaces the real error instead of hanging the pane on a spinner
- RichMarkdownErrorBoundary: contain the LazyChunkLoadError sentinel without a
crash report, but record a lazy_chunk_boundary_degraded breadcrumb
- EditorContent: name the rich markdown chunk at the lazy call site
Co-authored-by: Orca <help@stably.ai>
* fix(editor): route lazy-chunk recovery reload through the intentional-restart path
Crash b860def2's recovery reload was requested and never landed: Terminal's
beforeunload handler preventDefault()s while any editor tab is dirty and Electron
cancels the navigation with no dialog, so chunk recovery could never run in the
common case. Take the updater's path instead — hot-exit backup, one synchronous
session checkpoint, restart latch — then reload.
- Reject on ORCA_RENDERER_UNLOAD_PREVENTED_EVENT instead of a blind, never-cleared
10s timer; keep the timer only as a backstop.
- Record a lazy_chunk_reload_vetoed breadcrumb in the same tick as the report it
now files, so the 30-entry ring cannot evict the evidence.
- Drop this document's own stale guard after a refused reload (capped in memory)
so saving the blocking tab does not forfeit recovery for the session.
- Carry reloadKey on LazyChunkLoadError and the degraded breadcrumb.
- Move renderer-restart-preparation to src/shared: it is now a renderer/preload
contract, and the composite web project cannot import preload runtime code.
Co-authored-by: Orca <help@stably.ai>
* fix(editor): clean up failed lazy chunk reload requests
* test(preload): exercise restart IPC registrations
---------
Co-authored-by: Orca <help@stably.ai>
* fix(i18n): localize status labels in settings and stats
Status pills and summaries in Settings and Stats were built from bare string
literals inside local helper functions, so they stayed English even when a
language pack was active. Neighbouring copy in the same components already went
through `translate()`, which made the panes look half-translated:
"Универсальный доступ GRANTED", "GitHub ... Connected", Russian orchestration
card titles above English summaries.
The coverage audit does not catch this: it inspects JSX attributes and object
properties, not values returned by helpers, so `verify:localization-coverage`
stays green while the strings ship untranslated.
Wrapped the remaining user-facing strings in `translate()` and let
`sync:localization-catalog` add the 31 new keys. `computerUseSummary.*` already
existed in `en.json` with identical copy but was never wired up, so those keys
are now connected instead of duplicated.
Moved the permission status helpers into `developer-permission-status.ts` to
keep `DeveloperPermissionsPane.tsx` under the 400-line gate. Agent prompts in
`orchestration-usage-examples.ts` are left in English on purpose: they are
payload sent to the agent, not UI copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(i18n): use plural-aware message keys for settings labels
Replace template-based pluralization (using {{value1}} for "s") with
proper i18n plural forms following _one/_other suffixes. This enables
correct pluralization across languages with distinct rules.
Also extract duplicate integration status label translation logic
into a helper function.
* CodeRabbit's nitpick: the placeholder/plural assertions in settings-status-label-localization.test.ts only read en.json, so a translated catalog could ship a stale {{value1}} or a half-translated plural family undetected.
Added a second describe block over the four shipped locale catalogs (es, ja, ko, zh), keeping the exact English-value assertions untouched and separate:
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
* fix(speech): coalesce model download progress churn
A model download emits one state update per HTTP chunk (thousands for a
500MB model). Each one costs the renderer an IPC round-trip and a forced
re-render of the speech-model menu, which stays open by design while a
download runs — the Radix portal/Presence tree all four page.settings
React #185 reports crashed inside.
Emit at whole-percent granularity (what the UI renders) and keep the
modelStates array identity stable when a refresh changed nothing, so a
no-op refresh no longer forces a commit.
Adds a speech_model_state_churn breadcrumb, registered in both
coalescing sets, because no bundle in the cluster carried any speech
telemetry to confirm a download was in flight.
* fix(speech): quantise polled model state so download progress stops churning the renderer
Adversarial round 1 found the renderer-side stabilisation was inert during a real
download. The progress fan-out already coalesces to whole percent, but the renderer
discards the event payload and re-polls getModelStates, and getModelState returned
the cached downloading state verbatim - raw sub-percent progress. So every chunk
produced a fresh object, resolveModelStates never matched, and all the real benefit
came from the main-side coalescing alone.
Quantise the polled reply to the precision the UI already renders
(Math.round(progress * 100)), keeping the stored cache exact.
Also from round 1:
- the whole-status dedup swallowed downloadModel's already-downloaded branch, whose
lone 'ready' is the only notification the requesting window ever gets - a dead
click and a permanently stale pane with two windows open. Gate it on
downloading -> downloading so every one-shot transition stays unconditional.
- rename the storm test off '.react185.': it counts renders and asserts nothing
about #185, and #185 could not be reproduced at IPC-realistic pacing.
* fix(speech): stop the churn breadcrumb firing on every healthy download
Adversarial round 2. CHURN_REFRESH_THRESHOLD was 60 per 5s window, but main
clamps download progress at 0.9 and emits on whole-percent change, so one
healthy download is capped at ~91 refreshes — and a 56MB model on a fast link
lands all of them inside a single window. The breadcrumb fired on every normal
download and burned a slot in the 30-entry ring it was coalesced into to
protect. Raise it to 250 so it only fires on the per-chunk shape it was added
to detect; noOpRefreshes in the payload still separates the two causes.
Round 2 also found that every assertion round 1 added was one-sided
(toBeLessThanOrEqual), so each of the three core behaviours could be mutated
into "do nothing" with the whole suite still green:
- suppress every downloading -> downloading event: progress bar frozen at 0%
- toWholePercentState returning 0: every poll reports 0%
- resolveModelStates never adopting a same-length change: the Voice pane never
updates and a finished model never shows as ready
The suite measured that the fix reduces work, never that it still does the
work. Convert the two ceilings to exact series, assert the storm test's render
floor as well as its ceiling, and add dictation-model-state-stabilisation.test.ts
covering adoption per changed field, a full whole-percent download, and both
sides of the churn threshold.
Ruled out and deliberately not fixed: round 1's request-sequencing MEDIUM on
refreshModelStates. 50 concurrent getModelStates() settle strictly FIFO over
ipcRenderer.invoke at constant microtask depth, and migrationReady is assigned
once in the constructor, so a monotonic request id would be dead code.
* test(crash-reporting): cover speech churn breadcrumb name-coalescing
The churn breadcrumb's registration in COALESCED_RENDERER_BREADCRUMB_NAMES and
NAME_ONLY_COALESCED_BREADCRUMB_NAMES had no test: the storm test only asserted
the constant equals its own literal. Removing either registration kept the whole
suite green.
It carries no message field, so without the name-only entry
rendererBreadcrumbCoalesceKey returns undefined and every firing takes its own
ring slot — the eviction this breadcrumb exists to avoid.
* test(dictation): pin the churn threshold as a rate, not a lifetime total
Both existing churn tests stopped at exactly 250 refreshes in one window, so two
mutations survived: deleting the per-window reset (the counter degrades into a
session total, and three healthy downloads at 91 refreshes each cry wolf), and
=== to >= (fires on every refresh past the threshold, flooding the ring).
Pins Date.now rather than using fake timers so the window rule is measured, not
the machine.
* test(dictation): pin the churn clock and the window's lower bound
The two 250-refresh churn tests measured real elapsed time against a 5s
window, so a loaded runner that took longer than one window to run the
loop would roll the window and go red. Pin Date.now in both.
Pinning alone leaves CHURN_WINDOW_MS unpinned below: shrinking it 5_000 ->
50 kept all 13 tests green, yet a 50ms window can never accumulate 250
refreshes and the detector would be dead. Add a storm spread across most
of one window so the constant has to be wide enough to hold a sustained
storm, not just an instantaneous burst.
Co-authored-by: Orca <help@stably.ai>
* refactor(speech): narrow progress churn fix
---------
Co-authored-by: Orca <help@stably.ai>
* fix(dev): split the confirmation dialog so Fast Refresh can accept it
`confirmation-dialog.tsx` exported both `ConfirmationDialogProvider` and
`useConfirmationDialog`, so React Fast Refresh could never treat it as a
boundary and Vite applied every edit to it in two passes under two `?t=`
stamps. When a second file in the same subtree changed in one watcher batch,
`createContext` ran twice and the provider published one context object while
the consumer read the other — `useContext` returned null and the hook threw.
Two field crash reports hit this at `ChecksPanel`, both dev-server sessions.
The context and hook move to a new component-free `confirmation-dialog-context.ts`;
`confirmation-dialog.tsx` keeps the provider and now exports only a component, so
the refresh runtime accepts it. Not one line of the provider body changes — the 16
hook importers just point at the new module, `vi.mock` targets included, and
`App.tsx` is untouched.
* test(dev): pin the confirmation dialog Fast Refresh boundary
The split that fixed the context-identity crash had no test behind it: no
test imported ConfirmationDialogProvider, and the six vi.mock call sites
replace the hook module wholesale, so they pass just as well with the
provider and hook back in one file. Assert the module shapes the refresh
transform actually keys on -- the context module registers no component,
so it never gets an HMR footer to invalidate through.
Co-authored-by: Orca <help@stably.ai>
* test(dev): assert the refresh boundary on the module namespace
The source-regex guard did not guard. Its patterns match only declaration
forms, so `export { useConfirmationDialog } from './confirmation-dialog-context'`
in the provider module -- which restores the crash, verified in a browser --
passed it 3/3. It also failed on a comment that merely contained the word
createContext, and would fail on React 19's `<Ctx value={...}>` shorthand.
Assert on the module namespace object instead, using react-refresh's own
component criterion, so re-exports and default exports are visible. The third
test renders the provider and resolves the hook through it, which is a real
behavioural check rather than a shape one.
Co-authored-by: Orca <help@stably.ai>
* test(dev): classify boundary exports with the refresh runtime's own predicate
The hand-rolled `^[A-Z]` name check called `export class Foo {}` a component;
the runtime rejects any class whose prototype carries extra members, so that
shape restored the two-pass split undetected. Use react-refresh's exported
`isLikelyComponentType` and mirror `isCompoundComponent` instead of a third
approximation. react-refresh was already resolvable only via shamefully-hoist,
so it is now an explicit devDependency.
* test(dev): tighten confirmation dialog boundary guard
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): trust Pi CSI-u Shift+Enter on Windows (#9703)
Pi enables the Kitty keyboard protocol at startup and decodes CSI-u, but
TUI_AGENT_CONFIG['pi'] never set windowsShiftEnterEncoding, so on Windows
Pi could only get CSI-u via the flaky live-KKP-flag path
(isKittyKeyboardActivePane). After a tool ran a subprocess that emitted a
reset sequence, the KKP flags dropped to 0, Orca sent Esc+CR, and Pi read
it as plain Enter -> submit. It recovered on the next pane refocus.
Set windowsShiftEnterEncoding: 'csi-u' for pi, mirroring the Droid fix
(#7668), so the trusted CSI-u route covers Pi reliably independent of
KKP-flag churn from tool subprocesses.
* fix(terminal): complete Pi Windows CSI-u trust lifecycle
* test(terminal): name foreground retry timing
* test(git): accept bounded SSH remote probes
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* chore(dead-code): drop 2k lines of unreachable exports and orphan modules
Ran knip across every build entry (main, preload, renderer, popout, web,
cli, relay, workers, forked sidecars, config scripts) and removed what no
entry graph can reach.
- 11 orphan modules nothing imported, plus one test that only covered them
- 159 unused exports/types, with their now-dead helpers, imports and tests
Each candidate was verified against dynamic references before deletion.
42 knip hits were false positives and are kept: shared modules consumed by
the mobile/ workspace, the src/shared/plugins/** public API, vendored
shadcn primitives, and relay wire-protocol constants held for compatibility.
Adds knip.json + `pnpm audit:dead-code` so this stays measurable.
Verified: pnpm typecheck, pnpm lint, and 2081 tests across the 73 affected
test files all pass.
* chore(dead-code): move knip config under config/
Root-level additions are blocked by the root directory guard.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(workspaces): forget deleted remote mirrors
* fix(workspaces): tighten orphan cleanup guards
* fix(workspaces): avoid duplicate remote teardown after delete
* fix(workspaces): prevent orphaned filesystem auth on removal
When a worktree is deleted, especially from remote hosts, the filesystem
authorization cache was not being invalidated, leaving the path accessible
even though the workspace was gone. Use persisted host ownership to scope
cleanup to the correct partition and invalidate the auth cache when removing
a workspace to prevent orphaned authorization in host-partitioned scenarios.
* Fix orphaned worktree cleanup to trust persisted ownership and clean all
When a remote worktree or project is deleted, the local metadata cleanup must work even when the owning repo can no longer be resolved. The removal was incorrectly trusting a caller's potentially-stale hostId over the authoritative metadata, causing:
- SSH workspaces to be cleaned from only the local partition, stranding the remote partition with an un-bumped topology fence
- Sibling worktrees of the same repo to get rebased and lose unsaved tabs
- PTYs in orphaned workspaces to never stop when the selector can't resolve
- File watchers to keep firing events indefinitely
Now the cleanup trusts the persisted owner hostId, cleans all affected session partitions where tabs might live, intelligently gates topology fence bumps to avoid rebasing siblings, and passes the exact worktreeId to PTY sweeps that can't resolve the selector.
* Pass removal host ID to fix teardown of ownerless remote worktrees
When deleting an ownerless remote worktree, args.hostId may be absent.
Without an explicit host ID, the session teardown would incorrectly clear
the local session instead of the remote. Derive removalHostId from the
repo (the canonical owner) and pass it to every removeWorktreeMetadataAndTransientState
call to ensure the correct session is torn down.
* Scope worktree teardown to the owning host connection
- Orphaned SSH worktrees now sweep through the host's PTY provider instead of only the local one, so remote terminals die when the repo is gone
- Terminal ownership is scoped by resolved connection/runtime environment, preventing a same-id workspace on another host from being swept
- Persisted ownership beats stale live routing for in-flight keys and topology fences
- Renderer fails closed and never forgets a row whose removal route turns ambiguous mid-flight
* Fix worktree removal to scope session cleanup to the owning host
When a worktree is removed, its metadata purge must resolve the same owner
as the teardown sweep, or SSH/runtime partitions keep workspace state
forever. Additionally, materializing never-persisted host partitions
during removal can rebase sibling worktrees. Scope cleanup to owning host,
skip unwritten partitions, and detect transport-wrapped error codes that
Electron IPC re-wraps and strips causes from.
* Fix worktree removal to scope session cleanup to owning partition
- Only the owning partition may fence on emptiness; spill partitions
that never held the worktree must not claim repo authority to prevent
data loss when the renderer owns tabs elsewhere
- Tighten error code detection to require message boundaries (": " or
newline) instead of matching trailing tokens, preventing false
positives from triggering the destructive forget-local fallback
---------
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
* fix(quit): stop durable state writes from parking the main thread
will-quit ran stats.flush() and store.flush() synchronously, before
preventDefault(). Both fsync and rename a multi-MB file on the profile
directory. When that directory sits on a stalled network mount the
syscall enters an uninterruptible wait: the app stops repainting and
stops responding to Force Quit, because a process blocked in the kernel
ignores SIGTERM and SIGKILL alike.
The existing 20s teardown deadline could not bound this. Its timer runs
on the very thread the syscall parked, so it never fires. The fix is to
make the quit path awaitable rather than to try to bound it — a quit
that is slow but responsive stays killable by the OS.
- preventDefault() now runs first, so every teardown step is free to await
- stats and state gain flushAsync() twins that use node:fs/promises
- both join the existing teardown barrier, which can now actually bound them
- the pass-2 will-quit re-entry returns early instead of re-running teardown
- quitFlushStarted makes the quit flush the last write, so a teardown step
touching the store cannot arm a debounce that races process exit
Making the swap async cost the atomicity of check-generation-then-rename:
a writer parked on await rename has already cleared the guard, so a later
synchronous flush could be clobbered by stale state. Both async writers now
claim their temp path, and the sync writers delete it, turning that swap
into a swallowed ENOENT.
Atomic temp+rename is unchanged, so a write cut short by the deadline
leaves the previous file whole — bounded loss, never corruption.
* fix(quit): harden async persistence finalization
* fix(persistence): bound best-effort flushes
* Add first user prompt to AI Vault session history rows
Re-parse transcripts on demand to extract and display the untruncated first
user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads
it when session details expand. Grok sessions extract the typed ask from
<user_query> envelope, skipping injected <user_info> bootstrap rows. Supports
Claude, Codex, Grok, and OpenCode agents.
* fix(ai-vault): split SessionTime out to pass max-lines lint
AiVaultSessionDetails exceeded the 400-line oxlint limit after adding
first-prompt UI; move SessionTime into its own module.
* fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur
Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers.
* test(ai-vault): add first-user-prompt UTF-16 safety tests
Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs,
preventing corruption of astral characters in captured prompts.
* fix(ai-vault): key first-prompt-card by session.id
Remounting the card on session switches prevents late responses from
a previous load from writing stale data into the component's refs.
Also improves conversation-turn key stability.
* Tier GitHub PR lookup polling to prevent quota exhaustion
The selected worktree (O(1)) checks per-minute; card list (O(N)) per-15-minutes.
Introduce process-wide cache to collapse concurrent polling and gate lookups on
available rate-limit budget with exponential backoff on failure.
- Preserve last-known review during backoff
- Invalidate cache when Orca opens a PR
- Stop coordinator from double-charging
* Tier GitHub PR lookup polling to prevent quota exhaustion
- Return the latest reset time when both GitHub API buckets are rate-limited, preventing premature retries against still-blocked buckets.
- Serve the last known review on transient lookup failures, preventing reviews from blinking out on temporary errors.
- Discard in-flight lookups that predate an invalidation so stale answers cannot overwrite newly opened reviews.
* fix: give rate-limit reset tests unique titles
oxlint vitest/no-identical-title was failing static analysis because two
cases shared the same describe title.
* fix(watch/relay): bound remote watcher fan-out and read the live relay grace
Three P1 fixes from the SSH/remote freeze audit:
- Remote watchers now debounce on the same 150/500 window as local ones
(finding D), and every teardown path drops the trailing flush timer
instead of letting it fire into a dead watch. The deferred send is
wrapped so a frame disposed mid-window can't escape as a fatal
main-process exception.
- File Explorer refreshes are scheduled and concurrency-capped rather
than fanned out unbounded over expanded dirs (finding C). Local
transports use a zero window, since main already coalesced the burst.
- relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the
launch-time argv closure, so a grace raised after launch is honored.
The branch selection moves to relay-grace-branch.ts because relay.ts
has no exports and calls main() at import, making it untestable.
Consequence: a host-sleep relay holding zero PTYs now exits after the
idle cap. Pinned by test and documented in
docs/reference/relay-grace-time-reconfiguration.md.
Also drops the duplicated 150/500/5000 constants in the runtime-RPC
batcher in favor of the shared window module.
* docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit
Co-authored-by: Orca <help@stably.ai>
* refactor(file-explorer): use useMemo for paths; remove relay reference
Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle.
* rm design doc
* fix(watcher/explorer/relay): coalesce POSIX paths by byte identity; pres
- Remote watcher event coalescing now keeps NFC/NFD-distinct POSIX paths separate while still folding Windows path spellings, fixing cache invalidation when recreating directories with different Unicode compositions.
- Relay grace reconfiguration now preserves shutdown-deferred state when grace is set to zero, preventing premature shutdown when the grace timer is reconfigured mid-flight.
- File explorer refresh refactored from fixed-wave batching to concurrent task execution with `forEachWithConcurrency`, batching results every N settled reads instead of every wave, and reporting whether cancellation discarded pending work.
- Watch handler now resyncs when events arrive after disposal, ensuring refresh requests aren't lost when events race cleanup during worktree switches.
* fix(watcher/explorer): resilient commit batches on callback error
Cache writes precede callbacks, so a throwing callback cannot strand
the batch with stale marks. All callbacks complete despite errors,
with the first error thrown after.
- Fix commitBatchSize calculation for empty dirs
- Add scheduler discard-report test
- Move disposed variable before handleFsChanged
- Expand batching-strategy comments
* test(activity): stabilize portal readiness latch release under CI load
The fixed 9-flip budget could miss enough MutationObserver deliveries under
shard load for sibling DOM to still report loading. Drain readiness rAF after
each flip and wait until latched unavailable is actually observed.
* fix(watcher/explorer): stop batches on commit error; verify drain settle
Enhance resilience under CI load and error conditions:
- Portal readiness drain now returns a boolean confirming settlement; test expects verify the drain completed before proceeding.
- File explorer commit batches stop executing after a callback throws, preventing stale writes after caller-observed rejection.
- Watch hook requires worktree ID upfront in effect guard, eliminating redundant checks inside loops.
* fix(relay): re-arm grace on configured change
Refactor grace reconfiguration into a dedicated decision function. startGrace
samples the configured grace at arm time, so a raised value landing mid-window
would still fire at the old deadline without re-arming. Extract the logic with
proper tests to ensure shutdown-deferred state is preserved across re-arms.
* refactor(relay): extract grace reconfiguration to testable function
Extract `applyRelayGraceTimeConfiguration` so the grace-time re-arming
logic can be tested independently. relay.ts runs `main()` on import and
exports nothing, making the call site (including retryDeferredShutdown
hand-off into startGrace) otherwise untestable.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(watch/relay): bound remote watcher fan-out and read the live relay grace
Three P1 fixes from the SSH/remote freeze audit:
- Remote watchers now debounce on the same 150/500 window as local ones
(finding D), and every teardown path drops the trailing flush timer
instead of letting it fire into a dead watch. The deferred send is
wrapped so a frame disposed mid-window can't escape as a fatal
main-process exception.
- File Explorer refreshes are scheduled and concurrency-capped rather
than fanned out unbounded over expanded dirs (finding C). Local
transports use a zero window, since main already coalesced the burst.
- relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the
launch-time argv closure, so a grace raised after launch is honored.
The branch selection moves to relay-grace-branch.ts because relay.ts
has no exports and calls main() at import, making it untestable.
Consequence: a host-sleep relay holding zero PTYs now exits after the
idle cap. Pinned by test and documented in
docs/reference/relay-grace-time-reconfiguration.md.
Also drops the duplicated 150/500/5000 constants in the runtime-RPC
batcher in favor of the shared window module.
* docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit
Co-authored-by: Orca <help@stably.ai>
* refactor(file-explorer): use useMemo for paths; remove relay reference
Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle.
* rm design doc
* fix(watcher/explorer/relay): coalesce POSIX paths by byte identity; pres
- Remote watcher event coalescing now keeps NFC/NFD-distinct POSIX paths separate while still folding Windows path spellings, fixing cache invalidation when recreating directories with different Unicode compositions.
- Relay grace reconfiguration now preserves shutdown-deferred state when grace is set to zero, preventing premature shutdown when the grace timer is reconfigured mid-flight.
- File explorer refresh refactored from fixed-wave batching to concurrent task execution with `forEachWithConcurrency`, batching results every N settled reads instead of every wave, and reporting whether cancellation discarded pending work.
- Watch handler now resyncs when events arrive after disposal, ensuring refresh requests aren't lost when events race cleanup during worktree switches.
* fix(watcher/explorer): resilient commit batches on callback error
Cache writes precede callbacks, so a throwing callback cannot strand
the batch with stale marks. All callbacks complete despite errors,
with the first error thrown after.
- Fix commitBatchSize calculation for empty dirs
- Add scheduler discard-report test
- Move disposed variable before handleFsChanged
- Expand batching-strategy comments
---------
Co-authored-by: Orca <help@stably.ai>
* chore: condense code comments
* chore: shorten more code comments
* clarify PTY agent session descendant cleanup behavior
Refine the comment on ptyAgentSessionIds to more accurately describe
when agent sessions sweep their descendant process trees and note the
exception on immediate Windows shutdown.
macOS anchors a notarized Developer ID app's TCC grants on identifier +
team, which is cdhash-independent and so survives an in-place update.
Without a notarization ticket there is no such stable identity, so every
hourly reads as a different client: the grant row stays but stops
matching, and file access under Documents/Desktop/Downloads fails with
EPERM and no re-prompt. `tccutil reset` fixes it until the next build —
and orca-hourly has shipped as many as 14 builds in a day.
Skipping notarization was chosen because Squirrel.Mac validates the
replacement bundle's signature, not its notarization. That is true, but
it is the wrong requirement; the in-place swap was never the problem.
Budgets grow to absorb the notary round trip (publish 2x45, job 150), and
the App token is re-minted after the build so its one-hour life starts at
the first call that uses it rather than during `pnpm install`.
* fix(terminals): make redundant tab activation idempotent (React #185)
setActiveTab always reallocated activeTabIdByWorktree, even when the tab was
already active for that worktree. Terminal's active-terminal repair effect
depends on that map, so when the repair cannot converge activeTabId -- which
happens when an earlier-scanned worktree reuses the tab id -- the effect
re-triggers itself every commit until React throws #185.
Crash cluster A: 12 reports, boundary terminal.workbench, 1.4.162/1.4.163.
Co-authored-by: Orca <help@stably.ai>
* fix(terminals): converge activeTabId when a tab id is owned by two worktrees
Prefer the active worktree when resolving a terminal tab's owner. First-match
ownership left activeTabId permanently unconvergeable under a duplicated tab
id, so the active-terminal repair effect re-triggered itself into React #185.
Breadcrumb the duplicate-ownership state (once per tab id) so a crash bundle
can prove or kill the production origin of the precondition.
Co-authored-by: Orca <help@stably.ai>
* fix(crash-reporting): coalesce the duplicate-tab-owner breadcrumb
Its renderer guard is once-per-tab-id, so the stale worktree map it exists to
diagnose duplicates every tab id at once and could evict the whole 30-entry
ring. Also drops two keyed re-reads of tabsByWorktree that would throw for a
prototype-named worktree id, and pins the activeTabIdByWorktree guard with a
test that fails without it.
Co-authored-by: Orca <help@stably.ai>
* fix(crash-reporting): key the duplicate-tab-owner crumb on its convergence flag
Name-only coalescing keeps only the newest payload, so a resolvedToActiveWorktree
false sample — the one value saying the activation still could not converge — was
erased by any later benign true in the same 30s window. Keys on the flag instead,
mirroring the WebGL name:kind branch; two keys still bound the burst.
Also: the previous coalescing commit had no test at all (removing the name from
both sets broke zero of 2701 tests), the resolver's activeWorktreeId truthiness
check was a hole rather than a guard for a '' active id, and the resolver test
file failed oxfmt --check.
Co-authored-by: Orca <help@stably.ai>
* fix(crash-reporting): keep the non-converging duplicate-tab verdict
The two earlier commits contradicted each other. Splitting the coalesce
key existed so a `false` verdict could not be erased by a later benign
`true` — but the renderer guard was keyed on the tab id alone, so for
any one id only the first verdict was ever emitted.
A duplicated id that first resolves benignly, then stops converging when
the user switches worktrees, dropped the `false` sample at the source.
That sample is the whole reason the breadcrumb exists: it is the only
value saying the activation could not converge activeTabId.
Key the guard on id plus verdict. At most two crumbs per tab id, and
the main process still folds each verdict into its own ring entry, so
the flood bound is unchanged.
* fix(crash): correct the duplicate-tab verdict rationale, pin and cap the guard
Three comments said `false` is the verdict that matters because it is the
only one showing the activation could not converge. That is backwards.
The repair effect activates a tab drawn from tabsByWorktree[active], so
the React #185 path can only ever emit `true`; `false` is what a
deliberate jump-to-agent into a background worktree emits from a fully
converged state. A reader of the next bundle would have discarded the
exact sample the breadcrumb exists to capture.
The mechanism was right, only its stated reason was wrong: the real
justification for keying on the verdict is symmetric, since coalescing
keeps only the newest payload and either verdict would erase the other.
The suite also did not pin the "at most 2 per tab id" bound - a guard
keyed on `${tabId}:${activeWorktreeId}` passed all 11 tests while
emitting once per worktree, the storm the guard exists to prevent. Adds
a count-pinning test that kills it.
Caps the never-pruned guard set at 256 distinct verdict keys (~85KB),
mirroring MAX_COALESCE_KEYS. Measured 330 B/entry; a realistic thousand
duplicated tab ids is ~0.6MB, negligible but unbounded in principle.
* perf(crash): scan worktree tabs by key, and soften the verdict rationale
Round 6 corrected my own round-5 comment. I had written that `true` is the
repair-loop signature and `false` covers a deliberate background activation. The
repair effect can emit `false` too: its closure holds the worktree from its render
while the guard runs against live state, so a worktree switch landing in between
reattributes the tab. The verdict hints at the caller; it does not prove it, and
neither value should be discarded. Comment-only.
Also take the free scan win the perf review measured: Object.entries allocates a
pair array per worktree on a path that runs per tab activation. Own keys are safe
to index by, so Object.keys plus an indexed read is behaviour-identical
(16.1us -> 5.0us at 170 worktrees x 10 tabs).
* fix(terminal): keep a duplicated tab id from re-sorting the active worktree
setActiveTab now prefers the active worktree when a tab id is held by more
than one, but terminals.ts has a second, older owner resolver:
getTerminalTabOwnerWorktreeId, a memoized map built last-writer-wins. Two of
its callers — setRuntimePaneTitle and clearRuntimePaneTitle — use the result
for the same "is this pane in the active worktree" gate, so under a duplicate
the two resolvers disagree: the cache names whichever worktree it saw last,
which can be a background one for a pane the user is looking at. The gate then
fails open and every classified OSC title frame bumps sortEpoch, reinstating
the click-driven sidebar re-sort #209 removed — 20 title frames measured 20
bumps, each one a store write that re-renders every sortEpoch subscriber.
isTabInActiveWorktree answers from the active worktree's own tab list instead
of a tie-break. It stays behind the cheap id equality so the common
non-duplicated path is unchanged, and it is a hasOwn lookup plus one scan of
that worktree's tabs rather than resolveActiveTabOwnerWorktreeId, whose full
scan would run per title frame and whose breadcrumb would fold a second caller
into one verdict.
Leaves updateTabTitle and clearTabLaunchAgent on the cache: they pick which
copy of a duplicated tab to mutate, where no answer is defensible until the
duplication itself is fixed.
* test(terminal): pin the SSH-hydration origin of the duplicate tab id
Drives the duplicate from real hydration rather than constructing it: a
direct-SSH snapshot is keyed by worktree path, so renaming the worktree on
the host (or re-adding the repo, which mints a fresh id) re-resolves it to a
new worktree id while replaceHydratedRecordKeys retains the old key verbatim.
Nothing de-dupes across keys.
Fails on unfixed origin/main with converged=false after 200 passes; the two
precondition assertions pass on both sides, so the red is the non-convergence
itself and not a setup divergence.
The reconnectPersistedTerminals stub is load-bearing and marked as such: with
no registered PTY the orphan sweep cleans the duplicate up before the repair
effect sees it.
* docs(test): record the end-to-end #185 reproduction method on the regression test
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin the null-active-worktree guard in isTabInActiveWorktree
Dropping the `activeWorktreeId === null` early return was killed by nothing:
`Object.hasOwn(map, null)` coerces to the string key 'null', so a worktree
literally named 'null' would answer for "no active worktree". An untested
guard reads as dead code and gets deleted.
* rm triage context
* rm triage context
* rm context files
* refactor(terminal): extract repair logic into reusable hook and guard ag
Extract the active terminal repair effect from Terminal.tsx into `useActiveTerminalRepair`
hook to enable reuse in tests and clarify responsibilities. Replace falsy coercion guards
(`obj[id] ?? []`) with explicit `Object.hasOwn()` checks to handle edge cases: empty-string
worktree ids (valid but falsy), prototype-named ids like 'toString', and duplicated tab ids
across worktrees. Remove the now-unused `isTabInActiveWorktree` helper. Simplify the
isActive logic in terminals.ts to rely solely on owner-equality since the repair now uses
proper membership checks.
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Recovery logic strengthened with replacement-policy tiers (reuse/prefer-replacement/require-replacement) and bounded same-handle end cycles. Prevents infinite flapping by capping reuse attempts and inventory wait windows. Tracks ready evidence to reattach from prior snapshots when inventory becomes unavailable.
* batch remote watcher events and defer File Explorer refreshes
Remote filesystem watcher events now batch with the shared 150ms trailing and 500ms
max-wait window, coalescing per-path like local events. File Explorer tree and
directory refreshes are scheduled with debounce and transport-aware concurrency caps
(16 local, 8 runtime, 4 SSH). Stale directory cache tracking prevents trusting
collapsed listings skipped by full refresh; they are re-read on re-expansion. Relay
implements a 15-minute idle-only grace cap for zero-PTY relays via PTY pool
lifecycle tracking, independent of explicitly configured grace time.
* fix(watch/relay): bound remote watcher fan-out and read the live relay grace
Three P1 fixes from the SSH/remote freeze audit:
- Remote watchers now debounce on the same 150/500 window as local ones
(finding D), and every teardown path drops the trailing flush timer
instead of letting it fire into a dead watch. The deferred send is
wrapped so a frame disposed mid-window can't escape as a fatal
main-process exception.
- File Explorer refreshes are scheduled and concurrency-capped rather
than fanned out unbounded over expanded dirs (finding C). Local
transports use a zero window, since main already coalesced the burst.
- relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the
launch-time argv closure, so a grace raised after launch is honored.
The branch selection moves to relay-grace-branch.ts because relay.ts
has no exports and calls main() at import, making it untestable.
Consequence: a host-sleep relay holding zero PTYs now exits after the
idle cap. Pinned by test and documented in
docs/reference/relay-grace-time-reconfiguration.md.
Also drops the duplicated 150/500/5000 constants in the runtime-RPC
batcher in favor of the shared window module.
* docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit
Co-authored-by: Orca <help@stably.ai>
* refactor(file-explorer): use useMemo for paths; remove relay reference
Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle.
* rm design doc
* fix(remote-watcher): prevent stranded timer after close
An in-flight provider receive can land after the batch is torn down.
Without a guard, pushing events to a closed batch would re-arm a timer
that would never be cleared, stranding the task indefinitely. Track the
closed state and skip pushes after close().
Relay.ts comment clarifies why pool watches remain registered during
grace-period shutdown deferral — the socket server stays listening so
a reconnecting client can cancel the grace and resume.
---------
Co-authored-by: Orca <help@stably.ai>
Swap the worktree-name emoji picker from emojibase-data's `github` shortcode preset to `emojibase`, which carries both `flag_kr` and `south_korea` style flag names, and drop the hand-maintained `kr` entry that patched around the gap. Filter skin-tone aliases so they neither crowd the suggestion list nor clobber base-emoji branch names.
Search now matches anywhere in the shortcode, ranked exact > prefix > word-start > substring, so `:korea` surfaces both Koreas.
Emoji-derived branch names now prefer spelled-out aliases: flags use country names (japan, germany, south-korea) and cryptic stubs are skipped (thumbsdown over no, victory over v).
* fix(preflight): route landing banner through the runtime-aware preflight slice
Landing called window.api.preflight.check directly, which always probes the
local client. The preflight slice is the only caller that consults
getActiveRuntimeTarget and forwards to preflight.check on the active runtime
environment, so while connected to a remote runtime the landing banner
reported the client machine's git/gh state instead of the server's.
Delegate to refreshPreflightStatus and derive the issue list from
state.preflightStatus. This also drops Landing's duplicate probe: the slice
dedupes concurrent and forced checks, so the mount/focus/poll paths now share
one in-flight request with the rest of the app.
* fix(preflight): refresh landing status across runtime sessions
* test(preflight): cover paired runtime session races
* test: make landing preflight oracle behavioral
* fix(preflight): scope runtime session invalidation
* test(preflight): cover headed runtime switching
* test(preflight): isolate runtime status toast
---------
Co-authored-by: Marty <marty@localhost>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(remote): unthrottle host renderer while serving a paired client
A paired desktop host left in the background could not open or close
agent sessions for its remote/relay client: the action stalled and
eventually failed with the host-side "Timed out waiting for terminal
surface after creation" (10s) error, while an already-live terminal's
keystrokes stayed fast.
Root cause: creating/closing a session routes through the host
renderer's setTimeout-coalesced graph sync to publish the terminal
surface, but the host window runs with Electron background throttling
(the hidden-window default, reaffirmed on macOS). When the window is
backgrounded/occluded, those renderer timers are throttled to a crawl
and the surface publication misses the 10s deadline. Live keystrokes are
unaffected because PTY I/O flows through the main process, never the
renderer.
Keep the authoritative renderer unthrottled while at least one remote
client is connected and restore the throttled power-saving default once
the last one disconnects. Connect/disconnect are driven from the shared
MobileSocketWiring onReady/onClose, so both direct-WS and cloud-relay
clients are covered; headless serve has no window and is a safe no-op.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(remote): tidy renderer-throttle comment and test per review
Address automated review nits on #11581:
- Trim the module-level rationale comment to the non-obvious contract,
matching the repo's concise-comment guideline.
- Drop the dead `detachedThrottle` variable from the reapply test; the
detached-target scenario is already covered by the lazy-resolution
test, so the case now asserts only what it exercises.
No behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(remote): scope paired terminal publication throttling
Keep headed paired terminal creation and close renderer-owned so host inventory, input routing, ACK recovery, and cleanup retain the established lifecycle. Hold a reference-counted background-throttle lease only while the renderer publishes a paired operation, and epoch-fence async resolution so renderer reloads reject before any request or PTY spawn. Preserve headless main ownership and prevent paired clients from falling back to a local terminal.
* test(e2e): verify minimized host terminal repaint
* fix(remote): preserve paired terminal inventory through graph gaps
---------
Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>