* fix(terminal): require agent identity for guarded sends (STA-4028)
Quarter-circle spinner glyphs (U+25D0-U+25D3) started classifying a title
as "working" in #13925, and that status alone authorized guarded agent
sends — which auto-submit with Enter — into any pane whose TUI animates
those generic progress frames.
Keep the glyphs as an activity signal, but stop treating a title whose
only agent evidence is a quarter circle as proof an agent owns the pane:
send authorization now falls through to recognized-agent identity in the
title or a recognized foreground process. Braille-spinner behavior is
unchanged.
* fix(terminal): preserve verified managed busy identity
* fix(terminal): bind busy identity to process incarnation
* chore(test): avoid duplicate terminal gate suite
(cherry picked from commit cb41f3c5f4)
* fix(agent-title): treat Claude Code quarter-circle spinners as working
Claude Code 2.1.228 swapped its busy OSC title spinner from braille
(U+2800-U+28FF) to quarter circles (U+25D0/U+25D1). Orca recognized a busy
Claude title only by braille codepoints, so the new frames matched nothing.
The summary-bearing busy frame ("<glyph> Say hi in one word") carries no
"claude" name token, so it resolved to no-status. The tracker's "idle or
permission followed by no-status means the agent exited" rule then fired
mid-turn, confirmPtyAgentExit confirmed it, and the chat surface routed
exitChat -- kicking the tab to the terminal view on every message.
Widen the accepted glyph set via a shared containsAgentSpinnerGlyph helper.
Agent-specific braille frame shapes (Grok, Pi, synthetic Cursor) stay pinned
to their own glyph set.
Fixes#13889
* fix(agent-title): satisfy static analysis and trim scope
(cherry picked from commit 137e724119)
## What this changes
Two renderer inputs were trusted because their TypeScript types said they were valid. Both are now validated at the boundary that owns them.
**Terminal cursor style.** `normalizeTerminalCursorStyleDefault` preserved any non-null migration-stamped value without checking it against the actual enum, so a runtime value outside `bar | block | underline` survived into `terminal.options.cursorStyle`. It now enum-checks and falls back to `block`. Applied on both read paths (desktop `Store` load, web `getStoredSettings`) and both write paths (`Store.updateSettings`, web `settings.set`), so an unsupported value cannot reach persistence, the `settings:changed` publication, or xterm.
**Plugin language packs.** The renderer stored the `listLanguagePacks()` IPC result without a runtime check, so a non-array response was assigned to state and later crashed its first array consumer on `.find`. Ingress now accepts only an array, drops members failing `isPluginLanguagePackRegistration`, keeps valid siblings in their original order, and logs which failure mode occurred. The registration guard requires `resourceLanguage` to equal `pluginLanguageResourceId(id)`, which keeps a pack with a missing or inconsistent identity out of i18next — that shape reproduces as `TypeError: Cannot read properties of undefined (reading 'includes')` with the guard removed.
Catalog validation is shape-only on this path (`validatePluginLanguagePackCatalogShape`), so revalidating an already-parsed catalog does not allocate a second copy of it. `isCatalogObject` also now requires a plain-object prototype, and the walk rejects repeated or cyclic object references.
## Root cause: partially known
Worth stating plainly, because the fix is defensive rather than causal:
- The non-array pack container and the out-of-enum cursor value are both reproduced directly by tests.
- Two additional field stacks are *consistent with* an invalid `resourceLanguage` and are blocked by this guard, but the reports do not prove that malformed registrations were their source.
- No production writer in current code or history emits an out-of-enum cursor style. The Ghostty config importer (`src/main/ghostty/mapper.ts`) already rejects unsupported `cursor-style` values. The original writer is unidentified.
Every in-tree producer supplies valid data, so these guards are no-ops on the current happy path. They are boundary hardening against a mutation we have not located, not a repair of a known writer.
## Trade-offs
- Malformed registrations are skipped with one warning rather than surfaced in the UI. Valid siblings keep their identity and order. Note the main-process registry already reports per-plugin parse errors, so this path only catches corruption after main has validated.
- Renderer ingress walks each catalog once per lazy load or `contentPacksChanged`. It does not run on render or terminal-output paths, but a maximum-size burst still costs tens of milliseconds synchronously. Pack count and plugin-ID length remain uncapped.
- An unsupported cursor value now renders and persists as `block`, so anyone relying on an undocumented third-party value loses it.
- Loading settings whose cursor style is absent or invalid now marks state dirty and rewrites once, matching the existing `terminalRightClickToPasteDefaultedForPlatform` pattern above it.
## Compatibility
`listLanguagePacks` is local `ipcMain`/`ipcRenderer` only and is not implemented in the web build, so nothing here crosses the remote wire — no RPC parameter, publication schema, stream frame, opcode, or capability changed. Validation is receiver-side at existing boundaries. Nothing is platform-, shell-, PTY-, native-module-, SSH-, WSL-, or worktree-dependent, and the web build normalizes on both read and write. Malformed plugin data is rejected before i18next sees it; no new permission, network path, executable input, or persistence schema was added.
## Verification
Locally on the final head: the 5 touched test files pass (591 tests), plus node and web typecheck, oxlint, and oxfmt. All GitHub required checks pass, including Windows packaging, static analysis, Git and wire compatibility, shell contracts, and all 32 Node 24/26 shards. The path-gated E2E job is skipped after its detector passed.
Scope note: an earlier revision generalized this hardening to `Project.sourceRepoIds` and profile transfer without field evidence. That scope was removed; the diff is cursor and plugin paths only.
Forward the renderer's already-pinned {mergeBase, headOid} to the SSH relay so a single-file branch diff reads the two blobs directly instead of rediscovering live HEAD. Six sequential git processes become two concurrent reads, and a branch move mid-review no longer changes which revision is displayed.
Equivalence with the legacy route is proven against real Git across 14 change types; wire compatibility is proven over a real SSH socket against relay bundles built from main and from the pre-merge-base.
Once V8 optimizes the calling function, `String.prototype.codePointAt` on a
sliced string pairs a trailing high surrogate with the code unit that follows
the SLICE inside its parent, returning a code point the string does not
contain. Every UTF-8 byte scan built on `codePointAt` therefore reported one
byte too many for a prefix slice cut mid-pair, but only after tier-up, which
is what made terminal-stream-byte-length.test.ts fail intermittently on the
same commit.
Read the units explicitly with `charCodeAt`, which stays bounds-correct in
every tier, via a shared `readUtf8CodePointAt`.
* perf(renderer): keep catalog array identity and path-status cache on no-op refetches
mergeByIdentity always allocated a fresh array, so project-group and folder-workspace
refetches replaced state by reference even when nothing changed, refiring a forced
folderWorkspace.getPathStatus sweep per row on every repos:changed.
Unchanged merges now return the previous array, and the paired
folderWorkspacePathStatuses resets are gated on the same check so a no-op refetch
neither clears the cache nor needs to refill it. Real catalog changes clear and
refire exactly as before.
Co-authored-by: Orca <help@stably.ai>
* refactor(renderer): make the catalog arrays readonly
The identity guard can now return the caller's array, so a future in-place push or
sort on state.projectGroups / state.folderWorkspaces would alias store state. Typing
the merge helpers and the slice fields readonly removes both `as T[]` casts the guard
introduced and adds none, matching what #13744 did for state.repos.
Partial test fixtures cast the element rather than the array, so no `as unknown as`
laundering is needed.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
setRuntimeEnvironmentStatus always cloned runtimeStatusByEnvironmentId, so a re-probe
returning an identical status still invalidated every subscriber. It now returns the
state unchanged when the computed entry matches the stored one.
checkedAt is excluded from the comparison: it is Date.now() per probe and no consumer
reads it, so including it would make the guard dead code. Generation advance and the
disconnect toasts still run on every call.
Co-authored-by: Orca <help@stably.ai>
prepare() recompiled every statement, so orchestration reads re-parsed the same SQL
on the main thread. Adds a bounded LRU keyed by SQL, cleared on close() and before
schema-changing exec().
Wildcard selects are excluded: node:sqlite builds the first post-schema-change row
from stale column names, so a reused SELECT * can silently drop a freshly added
column. PRAGMAs stay uncached.
Co-authored-by: Orca <help@stably.ai>
The stream-gating hook (#13608) and the subscription parking installer
(#13621) each reimplemented `isWindowVisible() || isDocumentVisibilityProvenStale()`
plus its visibilitychange + stale-recovery subscription, and each carried
its own 500ms park delay that agreed only by coincidence.
Extract both into `lib/window-park-visibility.ts` and leave the two
consumers as thin wrappers: `useWindowStreamVisible` feeds the snapshot
and subscribe into `useSyncExternalStore`, the parking installer calls
them imperatively. The subscribe now always uses the defensive
`typeof document` guard the parking side already had, and registers
stale recovery before the visibilitychange listener so the stale latch
is cleared before the callback reads it.
Both park delays now derive from one `WINDOW_HIDE_PARK_GRACE_MS`: they
debounce the same signal against the same user behaviour (a quick
app-switch round trip). The parking side's 8x reveal-side backoff stays
where it is - that models its own resume cost, not the grace window.
Renderer-authoritative gating (#13622 native chat, #13634 editor panels)
is deliberately left alone; it keys off Zustand routing state, not OS
visibility.
Also switch the active-runtime ref writes in web-session-tabs-sync to
`useLayoutEffect` so no resume event can land between paint and the
passive flush, and correct the stale comment above them - the install
effect reads neither ref (both readers are gated on `isVisibilityResume`).
Mark invalidated diff content stale instead of dropping it, mirroring the file-content treatment from #13634. The diff read-generation fence (diffReadGenerationRef) already rejects a superseded read's write-back, so retaining the bytes and swapping them on arrival is safe and keeps every RPC saving -- it just stops the loading placeholder flashing on each reveal.
The git-status reload effect now treats a stale entry like a missing one so the lazy-load effect stays the single fetcher for it.
Co-authored-by: Orca <help@stably.ai>
* perf(native-chat): suspend hidden transcript streams
* fix(native-chat): keep assembly renders pure
* fix(native-chat): keep a transient error from stranding a revealed chat
- An error snapshot frame no longer latches frameArrived, so the in-flight
read can still seed the pane instead of leaving it on the error surface.
- Retained history is shown over a full-pane read error for the same source.
- The paged read window survives a hide/reveal; only a source change resets it.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): match the trimmed native-chat retention signature
The shared retention type no longer takes `loading`, so mobile's call was an
excess-property error that broke the mobile typecheck job. Dropping it also
lets mobile inherit the desktop behavior: a stream error or dropped client
keeps the last transcript instead of swapping it for the error empty state.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): park hidden remote browser screencasts
* test(renderer): pin stale-visibility term and parked-unmount teardown
Restores the WHY behind the '|| isDocumentVisibilityProvenStale()' term the
emulator-module dedupe dropped, and pins it plus the close-tab-while-parked
path with tests. Extracts the lifecycle harness so the spec stays under
max-lines without a suppression.
Co-authored-by: Orca <help@stably.ai>
* fix(ci): exclude test-support modules from the localization audit
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(remote): park hidden session mirrors
* perf(renderer): bound visibility resume cost and repair fences
- Back off the park delay after a park the user undoes quickly; each resume
re-enumerates every paired host, so short hide/reveal churn now stops
parking instead of paying that cost per cycle.
- Expire the active-worktree omission fence one visibility generation after
the inventory that set it, and name the unfiltered-inventory invariant it
depends on.
- Never skip the unchanged resume inventory for a worktree whose repair
replay is already armed.
Co-authored-by: Orca <help@stably.ai>
* fix(renderer): move active-runtime ref writes out of render
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(editor): park hidden external reloads
* fix(editor): sync content refs after commit
* fix(editor): close post-commit ref window
* test(editor): cover commit-time visibility
* fix(editor): keep invalidated content visible and stop redundant reads
- Mark invalidated file content stale instead of dropping it so a reveal swaps
bytes in place; the read-generation fence already blocks stale write-backs.
- Skip the lazy read when the newest read for the tab is still outstanding, so
a worktree flip-flop cannot double-fetch.
- Stop claiming Cmd/Ctrl+S outside the workspace view, where no mounted panel
owns the request and the keystroke was silently swallowed.
A write landing during a full scan was recorded by the post-scan gate stat,
so the next tick saw a clean gate and the create/delete waited up to 15 ticks
(~30s) for the backstop. Sample each gate dir's signature before its listing
so a racing write reads as stale and forces one rescan on the next tick.
Also fixes the flaky 'retires stale marker probes' spec: a folder caught
mid-rm opened a pending-marker window that the swallowed gate change kept
alive for the whole backstop gap (43 probes vs the 30 budget).
Co-authored-by: Orca <help@stably.ai>
* Add copy button to quick commands with visual feedback
Quick command rows now display a copy button that copies the command body to clipboard. The button shows brief visual feedback ("Copied" or "Couldn't copy") and is disabled when the command body is empty. Includes desktop and mobile UI, tests, and full i18n support.
* fix(ci): unblock verify for quick-command copy button
Key feedback to the copied body so prop changes drop stale labels without
setState-in-effect, and mock expo-clipboard in the mobile list test.
* perf(renderer): dedupe remote status hydration
* fix(runtime-status): re-list host catalog when the listing goes stale
Coverage matching cannot observe catalog edits made by another client or the
orca CLI, so gate hydration on catalog listing age too.
Co-authored-by: Orca <help@stably.ai>
* test(runtime-status): reset the catalog listing clock between tests
The staleness guard keeps its last-listed timestamp in module scope, so the
TTL test left fake time behind and made every following test in the file
depend on its position. Add a reset hook, call it in beforeEach, and pin the
TTL invariant on an externally added host rather than the timer alone.
Also extracts the coverage predicate in fetchSettings and restores the WHY
comment about compat failures clearing on a reachable status.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(codex): skip migration scans for nonshared reattaches
* fix(codex): require provenance to skip migration scans and bound ignored launches
Drops the exit-match escape hatch that skipped a scan without route provenance, and ages out ignored reattach launches on the existing 60s/256 lease policy so a lost exit can no longer pin an active launch (and its completion marker) for the process lifetime.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(runtime): avoid forced SSH hydration on project refresh
* fix(runtime): backfill SSH states for targets learned without a state read
A failed-connect resync labelled a target with no ssh.getState, and the new
metadata-refresh gates then skipped it forever: no SSH chip and every worktree
mutation to that host failed the connection-generation check.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): keep the repos array identity across no-op refetches
reconcileFetchedRepos deliberately returns the previous array when a refetch
changes nothing, so identity-keyed memos can skip work. Two later steps in the
same chain copied unconditionally and threw that identity away:
- reconcileReadoptedSshRepoRows spread the input on its no-prune path, which is
the common case.
- applyManualRepoOrder allocated a fresh array even when the saved order moved
nothing.
Both now return the input when they change nothing, so state.repos stays
referentially stable through fetchRepos, fetchRuntimeEnvironmentRepos,
fetchReposForAllHosts, and hydratePersistedUI.
Return type stays Repo[] with the same cast reconcileFetchedRepos already uses;
all four call sites only read the result.
Co-authored-by: Orca <help@stably.ai>
* refactor(renderer): make the store repos array readonly at the type level
Preserving the repos array identity means handing callers the same array that
is live store state, which previously relied on `as Repo[]` casts to launder
readonly inputs back into a mutable field. A cast is a footgun: the next person
to add a .push or .sort corrupts store state with no type error.
Widen RepoSlice['repos'] to readonly Repo[] and propagate honestly. Consumers
that only read take readonly Repo[]; genuine local accumulators are annotated
Repo[] and built from copies.
Removes all four pre-existing `as Repo[]` casts in the reconcile chain
(repo-identity-reconcile, superseded-ssh-repo-rows, manual-repo-order x2) —
this lands with fewer casts than main has today.
Type-only change; no runtime behavior differs.
Co-authored-by: Orca <help@stably.ai>
* fix(renderer): compare nested repo fields so reconciliation actually fires
Preserving the repos array identity was inert. reconcileFetchedRepos compares
repo fields with !==, but every repo the renderer receives carries nested
records that are new objects on every fetch:
- main's hydrateRepo unconditionally rebuilds hookSettings for every repo,
even a pristine one (persistence.ts:5109)
- IPC structured-clone (and the JSON hop for SSH/runtime hosts) reclones
gitRemoteIdentity, upstream, repoIcon, and the path arrays
So every repo compared unequal, `identical` went false on every refresh, and
the array was rebuilt regardless of the copies removed in the parent commit.
Compare nested plain records structurally instead. They are small sanitized
values; anything non-plain falls back to reference equality.
The end-to-end test previously passed for a fixture-only reason: its repo had
five scalar fields and the mock returned the same object both calls, the one
shape production never produces. It now uses a production-shaped repo and
fails without this change.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(settings): paste bash-native skill setup under WSL PTY
WSL worktree setup terminals force wsl.exe even when shellOverride is
powershell.exe. Auto-pasting the PowerShell `& { wsl.exe ... }` wrapper
into bash fails with a leading-& syntax error (#13305). Rewrite that
wrapper to a bash login-shell script for setup-terminal paste only;
clipboard copy still keeps the PS host wrapper for manual use outside Orca.
* fix(settings): align skill paste with resolved PTY shell
* fix(onboarding): keep shell preparation out of render
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* Fix static analysis page stuck in loading state
- Bound check-details requests with 30s timeout, matching remote RPC budget
- Track request IDs to discard stale responses when context changes
- Propagate githubRepository through store and components for proper routing
- Add retry button for failed check-details loads
- Improve accessibility with ARIA labels for loading and error states
* Fix static analysis page stuck in loading state
When an open check-details tab's repository is removed, the loading
state would continue indefinitely because the fetch was still being
triggered. Prevent the fetch call in this scenario to unblock the UI.
Also migrates translation keys to obfuscated identifiers.
* Fix static analysis page stuck in loading state
Add deadline-based timeouts and request ID tracking to prevent stale responses
from freezing the checks panel. Include abort signal propagation throughout the
request chain and provide retry UI for failed check details loads.
* fix(checks): prevent loading state from getting stuck on retry
- Consolidate mount checks into a helper function
- Details now clear when a new request begins
- Add i18n strings for retry status
* fix(sidebar): close the workspace rename editor opened by the shortcut
The workspace.rename shortcut opened the editor with setEditing, while
every other transition went through setEditingMode, which also mirrors the
flag into editingRef and guards on it. After a shortcut-opened edit the ref
still read false, so Escape, commit, and blur all returned early and the
editor stayed on screen until the card unmounted. The borderless sidebar
editor gives no sign it is still live, so the row looked like a finished
rename.
The editing element also took its React key from the display name and the
unread flag. Neither is an edit, but both moved the key, so React remounted
the input and the ref callback re-ran focus and select over a half-typed
name. That half was never shortcut-specific: the double-click and hovercard
editors lost typed text the same way.
Remove editingRef so the editing state is the only source of truth, route
both entry points through a single openRenameEditor, and keep the key on
the rendered title where remounting is how truncation gets measured again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): guard rename editor transitions
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
Every snapshot in a batch scanned the whole openFiles array three times and did
a linear find per editor tab, so a reconnect paid for open files those snapshots
never touched. Bucket open files by worktree for the duration of a batch, skip
the global rebuild when a snapshot has nothing to drop or mirror, and retarget
the index at the array each snapshot produced.
Per-snapshot semantics are unchanged: openFiles still flows through each patch,
so the equality bail is still evaluated per snapshot against the current array.
An earlier draft deferred that bail to the end of the batch, which let the batch
keep pre-batch file objects that sequential reconciliation rebuilds; the two
regression tests here pin both cases.
1,200 workspaces, 3,000 unrelated open files: no workspace with editors
99.7ms -> 3.1ms, 5% with editors 105.3ms -> 7.7ms. Where every workspace has
editors the array genuinely changes each snapshot, so the rebuild remains
(113.9ms -> 84.2ms).
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): unmount closed setup guide content
* fix(renderer): keep setup-guide progress live through the close linger
Dropping shouldRefreshCoreState on close disabled skill discovery and
computer-use permission state while the dialog was still fading out, so
'Agent capabilities' visibly un-completed mid-animation. Mirrors the
paletteStatusInputsActive guard from the worktree palette.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): unmount closed workspace cleanup content
* fix(workspace-cleanup): restore unconditional delete-state clear on batch error
The session extraction swapped the batch-level onError handler from an
unconditional clearWorktreeDeleteState to the conditional queued-only
helper. When the batch driver dies, nothing settles those rows later, so
a hung 'deleting' row stayed stuck in the sidebar and was permanently
excluded from retry by filterWorkspaceCleanupRemovalCandidates.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): release the relay-loss watcher before teardown mux writes
Cancelling the in-flight port scan emits rpc.cancel on the control lane.
If that lane is saturated, enqueue admission fails and the writer calls
fail() -> mux.dispose('connection_lost'), which fires the relay-loss
watcher during our own teardown and schedules a redundant relay redeploy.
teardownProviders already released the watcher first, but stopPortScanning
runs ahead of it and is what emits the frame. Hoist the release into a
named method and call it before stopPortScanning on all four teardown
paths.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): hoist the watcher release above abort on every teardown path
Review follow-up. Two nits from the readiness pass:
- The "stop scanning before teardownProviders" comment had drifted onto
releaseRelayLossWatcher(); move it back above the stopPortScanning()
call it describes.
- Release the watcher ahead of abortController.abort() too. That signal
reaches no mux request today, so this is not a live hole, but plumbing
it into one would have silently reopened the bug on three paths. Making
the release first keeps the invariant structural rather than incidental.
detachSshPtyConsumerRecovery stays first on the two detach paths, per the
existing synchronous-half-first rule.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): index GitHub resume lookups
* perf(renderer): skip the resume cache sweep publication when nothing changed
Every eviction helper already returns its input reference untouched when it
evicts nothing, so the opening sweep in refreshAllGitHub can compare identities
and return the previous state. Window resume then stops waking every store
subscriber on a no-op sweep.
Folds in the remaining unique optimization from #13711, which this PR
supersedes on the lookup-indexing side.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): preserve unchanged remote mirror resources
* test(renderer): restore layout-value coverage and record the workspaceId dependency
Notes why browser page identity reuse is safe: browserPageEqual must keep
comparing workspaceId, since the removed-workspace page-list cleanup gates on
it. Also re-asserts the effective active leaf that the patch-only assertion
stopped covering.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>