Disambiguate multi-host project groups and folder workspaces only at
catalog normalize, store CRUD, sidebar row keys, activation dual-read,
IPC, persistence, and filesystem-auth. Keep bare IDs when unambiguous
(including folder:folder-workspace-1); fail closed on collision. Leave
#13611 sticky geometry untouched and drop broad WorktreeCard/dashboard
session-routing propagation from the prior 66-file tip.
* fix(artifacts): gate agent artifact publishing behind an off-by-default capability
Public artifact sharing was reachable by any agent through `orca artifacts
share`: the Artifacts settings toggle only controlled sidebar visibility, and
nothing in the main process checked a capability before minting a public URL.
Add `artifactSharingEnabled` (default off) and enforce it in
ArtifactCloudService.share/update — before auth, network, or the share-record
write — so the CLI, relay-forwarded remote CLI, and IPC paths are all denied.
The denial carries a stable `artifact_sharing_disabled` code plus next steps
through the RPC error allowlist, so the CLI prints actionable guidance.
list, unshare, and delete stay ungated: turning publishing off must not strand
already-published links. The capability is absent from the `settings.update`
RPC schema, so an agent cannot grant it to itself — only the desktop UI can.
Co-authored-by: Orca <help@stably.ai>
* fix(artifacts): gate agent artifact publishing behind an off-by-default
Publishing is blocked until enabled in Settings → Artifacts. CLI preflights the capability before reading files to avoid unnecessary uploads. RPC surface rejects capability grants so callers cannot self-grant. UI shows opt-in workflow and recovery path when publishing is off. Web clients mirror the host's setting read-only.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(github-project): index fork upstream slugs for project row matching
Project cards often reference the public upstream repo while the open
clone's origin is a personal fork. Map the parent slug to the same Repo
so selected-repo filters no longer hide every board row.
Preserves origin-based getRepoSlug identity for non-project callers.
Fixes#12647
* fix(github-project): match project rows against fork upstream slugs
Resolve the referenced call to a nonexistent `resolveRepoUpstreamSlug` and
match the persisted `repo.upstream` parent instead of issuing an extra
`github.repoUpstream` RPC per repo on every index build — that lookup shells
out to `gh repo view` for non-forks, so it would have gated the Projects tab
on N network calls. `repo.upstream` is already resolved at repo-add time and
backfilled at startup, so the fix costs no IPC.
Origin matches take precedence over upstream ones so an open clone of the
upstream repo itself is never made ambiguous by someone's fork of it.
Also covers the two surfaces the origin-only match broke alongside the desktop
table: mobile's project row matcher and the store-slice row-mutation routing.
* fix(github-project): scope fork upstream matching by host and selection
Round-1 review fixes on top of the upstream-slug index:
- Apply origin-over-upstream precedence among *selected* repos instead of
globally. An open-but-unselected clone of the upstream repo was shadowing the
selected fork, so #12647 still reproduced for anyone holding both — and repo
selection collapses to one repo per project key, which is exactly that case.
- Scope a fork's upstream identity key to the fork's own origin host.
Persistence strips upstream.host, so GHES forks never matched their own rows
and a GHES fork's parent could bind a same-named github.com row.
* fix(github-project): skip the fork alias when its own origin is unresolved
Round-2 review fix. `githubHostFromIdentityKey` cannot tell "origin resolved to
github.com" from "origin did not resolve" — both yield no host. A GHES fork
whose slug resolution had failed (auth lapse, unreachable runtime) therefore
landed in the github.com namespace, so an unrelated public Project row matched
it and Start work opened the wrong clone on the wrong server.
Require a resolved origin before indexing the upstream alias: it is the only
host evidence there is, and a repo with an unresolved origin was already absent
from the origin index, so nothing is lost that origin matching had.
* fix(repos): persist the fork upstream host instead of dropping it
`sanitizeRepoUpstream` kept only `{owner, repo}`, so a fork's parent lost the
server it lives on every time the record round-tripped through disk.
That forced the Project row matcher to re-infer the host from `origin`. The
inference is right for an API-resolved fork parent — `getRepoUpstream` stamps
`origin.host` there precisely because "a fork parent lives on the same server as
the fork". It is wrong for the other branch: a local `upstream` remote carries
its own host, so a github.com clone with a GHES `upstream` remote was indexed
into the github.com namespace, where an unrelated same-owner/name public repo
could claim it and Start work would open the wrong clone.
Keeping the host removes the guess. Absent stays absent, so records written
before this hydrate unchanged and the origin-derived fallback still covers them.
Also fixes the avatar for rehydrated GHES forks, which resolved against
github.com for the same reason.
* docs(github-project): correct upstream host fallback comment
Persistence now keeps non-empty upstream.host; originIdentityKey remains
the host fallback for older records without one (CodeRabbit nit).
* fix(github-project): own slug-index retry timer cleanup
Move the failure-retry setTimeout into its own effect so cleanup always
clears it. Scheduling from the async buildIndex then-handler failed the
react-doctor effect-needs-cleanup gate in static analysis.
* test(github-project): guard the slug-index retry timer, fix the mobile twin comment
Two follow-ups on 52298d82 and 2f89c20d:
- Cover the retry timer both ways: a failed resolution still re-resolves after
the TTL and recovers the match, and the pending timer is gone after unmount.
The second fails if the timer moves back into the async then-handler, so the
property is guarded by more than the lint rule.
- The mobile matcher's comment made the same stale "persistence strips
upstream.host" claim that 2f89c20d fixed on the renderer side.
* test(github-project): unmount slug-index hooks so React cannot flush after teardown
CI shard `tests node 24 6/16` failed with 10 unhandled
`ReferenceError: window is not defined` traced to this file. The tests mounted
hooks without unmounting, so React scheduler work flushed after the DOM
environment was disposed. All assertions passed; the shard failed on the
unhandled errors alone.
`cleanup()` after each test unmounts the trees. Does not reproduce locally in
isolation — it needs CI's worker pooling and file ordering.
---------
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
httpProxyUrl is the only network setting stored via safeStorage. Two
failure modes silently killed the configured proxy on macOS:
- A keychain reset/denial makes decryptString throw at load; the raw
ciphertext then masqueraded as the configured proxy URL, so
applyElectronProxySettings silently fell back to DIRECT and the
garbage re-persisted forever (no self-heal).
- safeStorage.isEncryptionAvailable() throwing (keychain/API errors,
pre-ready use) was uncaught in encrypt/decrypt, failing the entire
state save - the data file never gained the proxy keys at all.
Load now validates the decrypted value and clears undecryptable
ciphertext (plaintext URLs still pass, preserving the pre-encryption
upgrade path), the availability check is exception-safe, and startup
logs when persisted proxy settings are invalid instead of silently
using direct networking.
* fix(terminal): stop transient probe blips from erroring restored panes (STA-3536)
terminal_pane_owner_unverified fired for every restored pane whenever one
liveness probe answer went missing: a cold-start daemon draining an attach
stampede misses the 2s getSize deadline, and a wedged superseded daemon
(protocol upgrades leave them running) turns every unmapped fan-out probe
null forever.
- probePtyOwners now skips legacy daemons whose startup inventory listing
succeeded: fresh sessions never route to them, so they provably don't own
an unmapped id and one wedged zombie can't poison every pane's verdict.
- attachStablePaneOwner retries the probe over a short backoff ladder before
surfacing unverified, so a single missed deadline resolves to a verdict.
- The renderer replaces the raw error code with actionable copy.
* fix(terminal): stop retrying definitive owner probes
* fix(terminal): recover live panes after renderer restart
* refactor(terminal): share owner resolution abort guard
* Reorder source control to show staged changes first by default
Stages are closest to the commit action and most relevant to the
commit workflow. Merges untracked files into Changes visually while
preserving their Git area. Removes the untracked-first preset and
includes migration logic for existing user settings.
* Drop source control group order user preference
Remove the sourceControlGroupOrder setting and related UI, migrations, and persistence logic. The source control view now always displays sections in the order: staged changes, unstaged changes, untracked files.
* Reorder source control to show changes before staged
Aligns with the edit-stage-commit workflow by showing unstaged
changes (active edits) before staged changes (queued for commit).
Per-target SSH teardown awaited `removeAllForwards` BEFORE anything marked the lease detached, so a slow forward close let the final store flush snapshot while leases still said `attached` — and the later durable write was rejected because persistence had already finalized. On the next launch those leases described a state that never existed.
`beginSshShutdown()` now performs every in-memory transition synchronously before returning, and the quit path calls it immediately before `store.flushAsync()` with no await between. The whole drain shares one deadline that REPORTS unfinished `{targetId, phase}` rather than concluding anything about it, and `waitForSystemSshForwardStop` gained a post-SIGKILL bound.
Nothing here destroys a session. `detached` means this app let go of the lease, not that the shell died — the pre-pass exists precisely so still-running PTYs are recorded as detached-but-alive instead of being lost to an `attached` snapshot. Review confirmed every reader honors that: reattach enumeration and persistence restore filter only `terminated`/`expired`, lease normalization has no age-based expiry, and attempt exhaustion leaves a lease alone. The drain deadline's only consumers are a warning log and a join that discards the value — nothing reads it as "gone".
Review also caught a defect the refactor introduced, now fixed: making the pre-pass synchronous meant a throw from `beginShutdownDetach` — via `webContents.send` on a renderer that quit had already destroyed — escaped the non-async `will-quit` listener and skipped `killAllPty()`, the watchers, `store.flushAsync()`, the teardown barrier and `app.quit()`. That would have lost the exact snapshot this PR exists to make correct. Each call is now wrapped per session, collecting errors and continuing. Proven: the test throws from the first of two sessions and fails without the fix with "Object has been destroyed".
A second test could only fail via timeout rather than assertion; the ordering is corrected so removing the post-SIGKILL bound now fails in 5ms with a clean assertion instead of a 5s timeout.
Rebased onto main and verified independent of #12673 (zero references to its owner-admission changes), which is being reworked separately. Fixes STA-3366.
* fix(repos): remove a paired computer's deleted projects from every connected device
A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.
Two independent defects:
1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
fed only by mutations arriving over runtime RPC, and clients refetch a remote
catalog only on a `reposChanged` event -- there is no polling on desktop -- so
the deleted rows persisted indefinitely. The shared `notifyReposChanged`
helper now also calls the new
`OrcaRuntimeService.notifyReposChangedForRemoteClients()`
(src/main/runtime/orca-runtime.ts:5175), mirroring the existing
`notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
project-group and folder-workspace IPC mutation, so renames, colors, reorders
and adds propagate too.
2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
answered `repo_not_found`. `removeProject` wrapped its whole body in one
try/catch, so the rejection aborted the local purge before the `set()`
(src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
to do nothing. Only `repo_not_found` is now tolerated; any other failure still
keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
three single-project user-initiated entry points. Bulk and background callers
keep today's silence plus their own aggregate reporting.
Closes#11994
Co-authored-by: Orca <help@stably.ai>
* fix(repos): revert inert RepositoryPane removeProject arg
The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.
Co-authored-by: Orca <help@stably.ai>
* fix(repos): scope duplicate-repo-id deletes to the owning execution host
Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.
Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.
Co-authored-by: Orca <help@stably.ai>
* test(mobile): align the poll-interval rationale with the new reposChanged emission
Co-authored-by: Orca <help@stably.ai>
* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host
The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
The sync before-unload checkpoint staged renderer state and then queued
store.flushPendingAsync() fire-and-forget, so reload/restart/update paths
navigated while the staged session, scrollback and UI state were still
only in memory. Quit is covered by the will-quit flush barrier; those
paths were not.
Keep staging synchronous (no sync durable writes), but record the flush
outcome and expose it on app:await-before-unload-checkpoint. Restart,
updater install and lazy-chunk recovery reload now join that write before
navigating and abort the attempt when it fails or outlives a 20s deadline.
* 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(P1-A): persist SSH consumer recovery without a sync store flush
rememberPtyConsumerRecovery ran on the live establish/reconnect path and
called flushOrThrow -> writeToDiskSync, parking the Electron main thread on
the profile-directory write. On a stalled or slow profile mount that freezes
the whole app during SSH recovery and reconnect.
Add Store.flushAsync(): same debounce-cancel and write serialization as
flushOrThrow, but awaits writeToDiskAsync instead of blocking. The consumer
recovery upsert/remove pair is now async and awaits it, and the SSH callers
await through to establish()/reconnect() so ownership is still durable before
relay setup continues. In-memory state still mutates synchronously (before the
first await), so no caller can observe a torn record and dispose() stays
synchronous.
* fix(P1-A): detach the SSH session when a connect attempt fails
Both failure exits in doConnect dropped the session from activeSessions
without calling detach(). claimSshPtyConsumerRecovery only reuses an existing
in-memory entry when detached === true, so the next connect attempt fell
through to minting a fresh clientInstanceId, discarding the remembered owner
lease and its resume identity.
Route both exits through abandonFailedSshSession(), which detaches (keeping
PTY ownership, unlike dispose()) before removing the session, and tolerates a
teardown throw so it can't mask the connect error being rethrown.
* fix(P1-A): await async lease persistence in SSH relay teardown
Failed connect attempts now wait for 'detached' leases to persist before
throwing, preventing reconnects from claiming them before cleanup completes.
Detach and dispose operations are now async and await store durability.
* fix(ssh): make session detach lease writes retryable on failure
Separate in-memory detach (identity recovery, provider cleanup) from lease
write persistence so rejected writes can be re-issued without re-running
provider teardown or re-minting the session identity. Introduce
flushDurableStateOrThrowAsync to flush only SSH-recovery state on the
live establish/reconnect path, avoiding snapshot writes of sidecars that
belong to quit/startup. Use Promise.allSettled in test reset to prevent
one rejected disposal from leaking state into the next test.
* fix(ssh): dispose mux on failed establish and propagate sync errors
- Dispose mux when session is disposed during establish to prevent resource leak
- Propagate synchronous errors in teardown via the completion promise instead of leaving completion undefined
- Add test coverage for terminated PTYs that exit mid-reattach and must stay dead
* 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
Use async profile probes and serialize the current-time due decision plus mutations under one async owner so sync flushes and detached writers cannot double-shift or miss the recovery interval. Add syscall, timer-liveness, parity, and held-I/O interleaving coverage.
* fix(persistence): tolerate null worktreeMeta entries in linked-item normalization
Co-authored-by: Orca <help@stably.ai>
* fix(persistence): drop null worktreeMeta entries instead of retaining them
gcStaleWorktreeMeta never reaps a timestamp-less key, so a skipped null meta
would persist forever and reach unguarded consumers (reassignSshTargetId,
push-target cleanup, disconnected-SSH listing, usage metadata).
Co-authored-by: Orca <help@stably.ai>
* fix(persistence): drop every corrupt worktreeMeta shape with its lineage rows
Widen the guard past null so scalars/arrays can't survive load into a
Record<string, WorktreeMeta> that consumers trust, self-heal a whole-map
"worktreeMeta": null instead of leaning on gcStaleWorktreeMeta running next,
and clear the two lineage companions the way gcStaleWorktreeMeta and
removeWorktreeMeta already do.
Co-authored-by: Orca <help@stably.ai>
* fix(persistence): persist whole-map metadata repairs
---------
Co-authored-by: Orca <help@stably.ai>
Prevent setup prompt inspection, caching, dismissal, saves, telemetry, and settings navigation from leaking across local, direct SSH, and runtime-relayed hosts.
* Link Jira issues from workspace create dialog
Add Jira issue linking to workspace creation, matching existing GitHub and Linear workflows. Users can paste Jira issue URLs in the smart name field to auto-populate workspace names and link the issue to the created workspace/worktree.
Linked Jira issues appear on workspace cards via the new 'jira-issue' card property. Implements cancellable searches and summary reads to prevent stalled requests from blocking the shared Jira pool. Persists paired issue + source context metadata with validation of provider/site identity.
Fixes git-username rate-limit handling to reject malformed JSON responses so garbage never becomes branch prefixes.
* feat(jira): link issues during workspace creation
- Display linked Jira issues on worktree cards
- Fetch issue summaries and timestamps via Jira API
- Gate Jira linking behind runtime capability check
- Preserve user-typed names during async lookups
* Enforce git check-ref-format rules in login validation
Extend isBranchSafeHostedLogin to reject usernames that git rejects as
invalid branch components: trailing dots, consecutive dots, and .lock
suffix. Prevents invalid branch names from login usernames.
* Enforce filesystem filename cap for branch-safe logins
Loose refs store logins as single filenames, so the real constraint is the
255-byte filesystem cap, not git check-ref-format rules. This allows longer
provider-agnostic logins while staying platform-safe.
* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)
Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.
Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.
* fix(plugins): make unsupported marketplace listings unreachable by key
findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.
* fix(plugins): fan Pi session-only status out to plugin subscribers
The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.
Co-authored-by: Orca <help@stably.ai>
* plugins: drop dead code and hoist duplicated trust-boundary patterns
Cleanup pass over the P1 diff, no behavior change:
- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
`mapWithConcurrency`, whose index arg also removes the pairing wrapper
in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
paths, matching every other gate's link form.
* fix(plugins): retry plugin state renames on Windows AV/EPERM locks
Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.
Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.
* test(plugins): cover the Windows rename retry path
The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): pin bundled plugin resources to LF
Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.
Co-authored-by: Orca <help@stably.ai>
* test: guard the bundled-plugin LF pin against a CRLF checkout
The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.
Co-authored-by: Orca <help@stably.ai>
* ci: trigger packaged-build check on bundled plugin resource changes
The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.
Co-authored-by: Orca <help@stably.ai>
* perf(plugins): rebuild the panel frame only when its baked theme values change
The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.
Co-authored-by: Orca <help@stably.ai>
* test: stop pinning a plugin name in the CRLF guard
The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.
Co-authored-by: Orca <help@stably.ai>
* style: drop stray blank lines left by the rebase resolutions
Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.
Co-authored-by: Orca <help@stably.ai>
* test(plugins): stop the startup budget failing on machine load
P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(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(terminal): make Zellij/TUI OSC 52 clipboard copy work by default
Zellij and other multiplexers copy via OSC 52. Empty Pc is a valid XTerm
default for clipboard, but we rejected it, and the feature defaulted off so
copy silently failed inside Zellij. Accept empty Pc as clipboard, default the
setting on (query still blocked; size capped), and surface Zellij in settings.
Closes#10567
* fix(review): make the OSC 52 default actually reach existing installs
Review fixes for #10588:
- Persistence: profiles saved under the old off default persisted `false`,
which is indistinguishable from a real opt-out, so the default flip never
reached #10567's reporter. Added the repo's one-shot stamp
(terminalAllowOsc52ClipboardDefaultedOnForAllUsers) so unmigrated profiles
flip once and a later opt-out sticks.
- Replay: reattach/cold-restore re-writes recorded PTY bytes through the same
parser, so a stale `\e]52;c;...` silently clobbered the clipboard on every
restart. Gated behind isPaneReplaying via a new resolveOsc52ClipboardGate.
- Blocked toast latches once per renderer session and could be burned by a
pre-hydration read; it now fires only for a real opt-out.
- An empty Pd decoded to '' and, with the gate default-on, silently blanked
the clipboard. Now rejected as invalid.
- Localization: en.json is bundled and the catalog beats the code fallback,
so all three copy changes were inert. Resynced across five locales.
- Corrected the empty-Pc rationale: tmux (not Zellij) emits `\e]52;;<b64>`.
* test(terminal): cover the OSC 52 gate wiring and settings copy
Extracts createOsc52OscHandler so the replay/hydration gate wiring is
covered, not just the pure gate — dropping the isReplaying getter now
fails a test instead of passing silently.
Adds catalog assertions for the two OSC 52 settings strings. Only the
toast key was pinned, so the same inert-copy regression (code fallback
edited, bundled en.json not) could still ship for the settings pane.
* docs(settings): note that the OSC 52 default only covers new profiles
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): migrate the web settings store to the OSC 52 default-on flip
The default-on flip only reached the Electron store. The web/remote client
keeps its own settings in localStorage, so a profile that persisted the old
`false` there stayed opted out — the same bug the Electron migration fixed,
in the second store.
Extract the migration into shared/osc52-clipboard-settings.ts and call it
from both stores. Also coalesce OSC 52 writes onto a microtask so a hostile
chunk of ~15-byte sequences cannot fan out into a million clipboard writes,
and latch the blocked-write toast after it renders rather than before.
* feat(terminal): tell users when the OSC 52 flip overrides their opt-out
The default-on migration cannot distinguish a deliberate opt-out from a
profile that simply never touched the setting — both persisted `false` under
the old default. Flipping everyone is the only way to fix#10567 for existing
installs, but doing it silently reverses a security choice the user made.
Arm a one-shot notice at load when the migration overrides a persisted
`false`, on both settings stores, and show it once the renderer hydrates.
Profiles that never opted out are never notified.
* fix(terminal): clear the OSC 52 notice after it renders, not before
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the web OSC 52 notice armed against an unmigrated host
The host store always projects osc52ClipboardDefaultOnNoticePending, so the
plain spread in the web client's runtime UI merge overwrote an arm raised by
its own localStorage settings migration — flipping the opt-out in silence.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): stop the OSC 52 notice overclaiming, and cover it
Round-3 review fixes:
- Rename the arming predicate to osc52ClipboardDefaultOnOverridesPersistedOff.
Both stores rewrite the whole settings object on every save, so every profile
saved under the old off default holds `false` — the deliberate-opt-out cohort
is not distinguishable on disk. Name, docs and test names now say so.
- Read settings before the UI snapshot in readLocalWebUIState: getStoredSettings()
arms the notice, so reading first snapshotted a pre-arm state that callers wrote
back, erasing an arm the stamp can never raise again.
- Give the notice toast a stable id; StrictMode re-runs the effect against the
same closure, so the early return cannot catch the second pass.
- Restore guardParserHandler parity in the coalescer microtask.
- Drop the unverified Zellij claim justifying all-selections routing; that routing
predates this branch and PRIMARY routing stays an open question.
- Cover the notice hook (order, single-fire, deep-link), the armed flag reaching
disk and surviving a clear, and pin the notice catalog to its code fallbacks.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin OSC 52 setting discovery by product name
The migration notice says to turn it off in Terminal settings, so searching
Zellij/Grok/tmux has to find it. Also note why the OSC 52 write-back clauses
stay despite an unrelated always-true clause in the same condition.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): consume the OSC 52 notice on close, and cover the guards it relies on
The notice was cleared the moment the toast was enqueued, so a quit inside its
15s window spent the profile's only warning on a launch where nothing was ever
seen — and the settings stamp means it can never re-arm. Clear on
onAutoClose/onDismiss instead, plus explicitly in the action handler, because
sonner's action path deletes the toast without firing onDismiss.
Also closes three coverage gaps a review found:
- ui.set must accept osc52ClipboardDefaultOnNoticePending. The update schema is
strict, so dropping the key rejects the whole call rather than stripping it,
and the renderer only logs that failure — every paired client would re-toast
forever with nothing red.
- the coalescer's try/catch and .catch had no test; the rejection case needs a
plain function because vi.fn tracks settled results and hides the leak.
- pin that every selection kind (including bare `p`) lands in the system
clipboard, so routing PRIMARY separately later is a deliberate break.
Co-authored-by: Orca <help@stably.ai>
* test(web): pin that ui.get arms the OSC 52 notice when it runs the migration
readLocalWebUIState reads settings before the UI blob so the migration's arm is
in place before the snapshot every caller writes back. Seeding localStorage
after install is what makes ui.get the first settings read, and therefore what
makes swapping those two lines fail.
Co-authored-by: Orca <help@stably.ai>
* test(store): cover the OSC 52 notice clear and its hydration
The clear sets local state before persisting so a rejected ui.set cannot leave
the toast re-firing for the rest of the session; losing the persist only re-arms
the notice next launch.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): state the real residual risk of default-on OSC 52
Three comment corrections from review:
- the safety note claimed exfil was the risk; queries are blocked, so it isn't.
The actual accepted risk is execute-on-paste: decoded text goes to the
clipboard verbatim, newlines included. Filtering here would break multi-line
TUI copies, which is the feature; bracketed paste is where that is handled,
and kitty/Ghostty take the same posture.
- the coalescer bounds a flood per parse yield, not overall.
- the replay gate reads at parse time while queued live bytes are drained
before the guard engages, so a copy racing a reattach is dropped silently.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): close the four OSC 52 gaps a full revert walked through
Mutation testing found four assertions that stayed green against the very
change they were written to pin.
The notice suite passed 8/9 against a complete revert to clear-at-enqueue:
`calls[0][1][callback]?.()` is a silent no-op when the option is absent, and
the call count was already satisfied by the enqueue-clear, so nothing
separated "cleared by this callback" from "cleared earlier". Assert the
option exists and the notice is unspent before invoking it.
The stable toast id was deletable with all 9 green despite the adjacent
comment calling it load-bearing for StrictMode. Pin it.
The blocked toast's latch-after-throw fix was unproven: both orderings pass
when `toast.info` succeeds. Only a throwing first call tells them apart.
Deleting the hook call in App.tsx silenced the desktop notice with every
suite green. Pin it alongside the static Toaster import, since sonner drops
a toast enqueued before any Toaster subscribes and never replays it.
Also retone the coalescer-latch comment, which claimed the reset ordering
was load-bearing on its own; the try/catch reaches the same end, so the
test binds the pair.
All four verified green->red by mutation, then restored.
* test(terminal): cover the OSC 52 notice and its guards
Add tests pinning the static Toaster mount required to prevent notice dropout (#10567), the stable toast ID deduping StrictMode double-invokes, that the notice stays unspent on toast throws, and that flush-latch guards prevent silent consumption across error boundaries.
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(tasks): keep repos with a pending remote-identity probe in the picker
Task-repo eligibility filtered on `hasProjectRemoteIdentity`, which is
populated by a background `git remote -v` probe. When the probe could not
reach git — an SSH-hosted repo whose connection is not up yet, a cold
launch — the repo silently vanished from the Tasks picker and stayed
hidden for the full 5-minute negative-cache TTL, even after the host came
back. GitHub repos were largely shielded because a persisted `upstream`
satisfies the identity projection through a different route; GitLab and
other providers depend on the probe.
Distinguish unknown from settled instead of hiding both:
- `probeGitRemoteIdentity` reports `resolved` / `no-remote` (git answered,
no usable remote) / `unavailable` (never reached git).
- Enrichment persists `gitRemoteIdentity: null` only on `no-remote`,
mirroring the existing `upstream: null` "not a fork" marker. An
unreachable host leaves the identity undefined.
- Persistence keeps the explicit `null` instead of dropping it.
- `getTaskEligibleRepos` keeps a repo whose identity is still pending;
folders and settled remote-less repos stay filtered out.
* test(tasks): cover the SSH probe exec paths for remote-identity status
Addresses CodeRabbit review: the unavailable-on-error case only exercised
the local git runner. Adds a connected-provider whose exec rejects, and an
SSH repo git answered for with no remotes.
* test(tasks): pin that a settled no-remote repo still resolves once it gains a remote
Three independent reviewers flagged that the candidate filter's `!repo.gitRemoteIdentity`
looks like an oversight next to the new null marker. Tightening it to `=== undefined`
would silently stop detecting a remote added after the marker landed. Document that the
re-probe is deliberate and pin the behavior with a test.
* fix(rate-limits): keep Codex PTY reset text for weekly-only plans
The PTY /status fallback parses '5h limit' and 'Weekly limit' lines by
label, but the extracted reset text was only ever attached to the
session window. Codex plans without a 5h session bucket (e.g. current
Pro) produce a weekly-only parse, so the reset time the CLI printed was
silently dropped. Fall back to the weekly window when no session window
exists.
* review: parse Codex PTY reset text per window into resetsAt
* review: make Codex PTY status fallback work on codex >=0.145
* review: harden PTY status parse against model-scoped rows and styled output
* fix(rate-limits): strip private PTY control sequences
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix: prune workspace session state when a project is removed
removeProject → pruneWorktreeStateForRepo cleared worktreeMeta, lineage,
and workspace lineage, but left the removed repo's worktrees behind in
workspaceSession (lastVisitedAtByWorktreeId, sleepingAgentSessionsByPaneKey,
tab state, …) and in every workspaceSessionsByHostId partition.
Those dangling references caused the runtime to treat the deleted repo's
worktrees as recently-active on the next launch and re-materialize their
worktreeMeta. With no owning project left, the UI rendered them as an
orphaned "unknown" workspace that returned after every restart.
Reuse the existing removeWorkspaceSessionOwner cleanup (already used by
removeFolderWorkspace and deleteProjectGroup) against the legacy session
blob and each per-host partition, collecting owner keys before the
worktreeMeta delete loop so host classification still works.
Fixes#9024
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: scope session cleanup to the removed host in removeProjectForHost
The workspace-session cleanup applied every collected owner key to the legacy
(local) session blob AND every workspaceSessionsByHostId partition. Owner keys
are `${repoId}::${path}` and carry no host, so a host-scoped removal
(removeProjectForHost) of one host wrongly wiped a surviving host's session for
a shared repo id/path — discarding its lastVisitedAt, tabs, sleeping-agent
state, and active-worktree pointer.
Scope the session prune to the removed host: prune the legacy blob only when the
target host is local (or on a full removeProject where hostId is null), and
prune only the matching workspaceSessionsByHostId partition for a non-local
host; a full removal still clears every partition. Collect session owner keys by
`${repoId}::` prefix (not belongsToHost, which is worktreeMeta-host-classified)
so a partition-only key is still prunable.
Adds regression tests for a shared repo id + path across local and an SSH host,
covering removeProjectForHost on the SSH host (local session survives) and on
the local host (SSH session survives).
Addresses review feedback on #9024.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover third surviving host in removeProjectForHost session prune
Add a regression case where a shared repo id + path exists on local and two
SSH hosts. Removing one non-local host must prune only that host's session
partition, leaving both the legacy/local session and the other surviving SSH
host's partition intact. Locks in that the host-scoped cleanup never touches a
non-targeted third partition (per review feedback on #9024).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf: prune workspace session in a single clone+scan per partition
Project removal collected every owner key to prune, then called
removeWorkspaceSessionOwner once per owner AND per host partition. Each
call structuredClones and scans the entire session, so removal was
O(worktrees x hosts x session size) in full-session clones on the main
thread.
Add removeWorkspaceSessionOwners(session, ownerKeys) which clones once
and scans each collection once for the whole set, and route
pruneWorktreeStateForRepo through it. The per-owner O(1) field deletes
and the single-scan pane-key passes are factored into shared functions
so removeWorkspaceSessionOwner keeps identical behavior for its other
callers. No behavior change.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: PannenetsF <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): persist per-device tab selection so worktree return restores the last open tab
A phone's tab selection lived only in the host's in-memory
ClientSessionTabSelectionStore. Any host restart wiped it, and the
per-device projection then fell back to deterministic topology, so
returning to a worktree on mobile always landed on the first tab
instead of the tab last opened on the phone.
Persist the per-device selections in the Store (keyed deviceId ->
worktreeId), hydrate them when the runtime constructs, and guard
projection so an early empty snapshot after restart cannot wipe a
hydrated selection before tabs arrive. Selections are pruned with the
worktree/repo and on device revoke, and malformed persisted payloads
degrade to empty instead of throwing.
* fix(mobile): harden persisted tab selection cleanup
* fix(mobile): preserve tab selection across worktree rename
## Description
Every durable save serialized the ~1.5 MB durable state **twice**, synchronously on the main Electron thread: once in `computeStateHash()` (the plaintext hash for the no-op-write guard) and again in `buildStateToSave()` (the encrypted on-disk payload). The guard couldn't hash the payload directly because `encrypt()` on random-IV platforms produces different bytes each save for identical state.
This PR collapses the two into **one** `JSON.stringify`. `buildStateToSave()` now returns `{ payload, stateHash }`: each encrypted secret slot is serialized as a **fresh per-slot random sentinel** (`orca-secret-slot-<uuid>`), and after the single stringify each sentinel is substituted exactly once — to its ciphertext for the on-disk `payload`, and to its plaintext for the guard `hashInput`. The guard hash is therefore a pure function of the plaintext state (`hashInput === JSON.stringify(plaintext durable state)`), and the on-disk `payload` is byte-identical to the previous two-stringify output.
No change to the save API, debounce timings, guard semantics, or on-disk format (compact JSON, same 3 encrypted fields).
### Why sentinels (not a string replace of the ciphertext)
The substitution must be **position-exact**. A naive `payload.replace(ciphertext, plaintext)` — or even a `"key":"ciphertext"`-anchored replace — can be **mimicked by user-controlled state**, which would substitute the wrong site and let two *distinct* states normalize to the same guard hash → a real change is silently **not written** (data loss). This is reachable on deterministic-IV platforms (macOS / legacy-Linux OSCrypt use a constant IV, so a user can read their own ciphertext out of `orca-data.json`):
- **Value vector**: a plaintext free-text field (e.g. `httpProxyBypassRules`) whose value equals a secret's ciphertext.
- **Key vector**: `agentDefaultEnv` lets the user name an env var exactly after a secret field (e.g. `browserKagiSessionLink`) with a ciphertext value, producing a `"browserKagiSessionLink":"<ciphertext>"` token inside `settings` (before `ui`).
A per-slot random sentinel is minted **after** all user data is captured, so it cannot occur anywhere else in the serialized state and matches only the intended secret slot — closing the entire class. (Both vectors were found by an adversarial fable→gpt-5.6-sol review loop; each has a store-level regression test that fails on the earlier approaches and passes now.)
The empty-secret / `safeStorage`-unavailable / encrypt-failure cases (`blob === plaintext`) get no sentinel and reproduce `main`'s behavior byte-for-byte.
## Evidence
- Full-state `JSON.stringify` calls per changed save: **2 → 1** (call-counting test).
- Frontier-review benchmark on an 8 MiB real-method state: **no perf regression** (median 12.73 ms sentinel vs 13.77 ms two-stringify baseline).
- Independent review modeled 625 adversarial durable states: every payload byte-identical to the pre-PR implementation, and every distinct state produced a distinct guard hash (no dropped-write collision).
## No-Regression Proof
`src/main/persistence-single-serialize.test.ts` (nondeterministic **and** deterministic cipher mocks):
- identical state with secrets set → no-op (inode unchanged); real change (incl. secret rotation) → writes
- on-disk payload is ciphertext (plaintext appears nowhere), round-trips through reload/decrypt
- empty secret + `isEncryptionAvailable() === false` → plaintext payload, guard still skips
- sync `flushOrThrow()` also skips on identical state
- exactly one full-state serialization per save
- **regression (value vector)**: a plaintext field equal to a secret's ciphertext, then swapped → write is not skipped, round-trips
- **regression (key vector)**: an `agentDefaultEnv` var named after a secret field holding its ciphertext, then swapped → write is not skipped, round-trips
Suites: `persistence-single-serialize.test.ts` + `persistence.test.ts` all green; `pnpm run typecheck:node` clean; oxlint clean.
## ELI5
Orca saves your app state whenever something changes, and it used to build the whole (large) state twice each time — once to check "did anything actually change?" and once to write the file. Now it builds it once and reuses that copy for both jobs. To compare states safely it swaps each encrypted secret for a random one-time marker while checking, so no value you type can ever be mistaken for a secret and trick it into skipping a real save. What lands on disk is exactly the same, and your secrets stay encrypted.
Made with [Orca](https://github.com/stablyai/orca) 🐋
* fix(accounts): follow the runtime for WSL provider account detection (#9537)
On Windows + WSL, provider-account detection (usage recognition and the
status-bar account switcher) was pinned to the Windows host even when the
project runs in WSL, so WSL accounts were never recognized and the WSL
switcher group never appeared.
Root cause: `localAccountRuntime` hard-defaulted to 'host', which
short-circuited `getInitialClaude/CodexRateLimitTarget` before the existing
"follow the global Windows runtime default" branch could run. That branch was
therefore dead for every real user.
Fix: add an 'auto' value for `localAccountRuntime`, make it the default, and
migrate the untouched legacy 'host' default to 'auto' once (guarded by
`localAccountRuntimeDefaultedToAutoForAllUsers`; explicit 'wsl' is preserved).
'auto' resolves via a shared `resolveLocalAccountRuntimeTarget` helper: on a
windows-host default it stays host (no behavior change); on a WSL default it
follows WSL, so WSL accounts are recognized and the WSL group appears.
Wired the shared resolver into the managed-account default target, the
status-bar WSL-group gate, and the Accounts settings location toggle.
Note: detection follows the global Windows runtime default, not the live
active project's runtime (the fetch target is a single global value); the
latter is a larger follow-up.
* fix(accounts): align auto runtime consumers
* fix(accounts): keep runtime polling aligned with settings
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* feat(status-bar): consolidate agent usage into a single roster popover
The footer usage cluster is now one quiet, borderless control: each agent
shows its tightest window as neutral text (letter badges at icon-only
width), and clicking anywhere opens a consolidated Usage popover listing
every agent worst-first — icon, name, plan, soonest reset, and per-window
threshold-colored bars. Claude/Codex rows drill into their existing
account switcher, runtime toggle, and Codex reset credits via a submenu
variant of ProviderDetailsMenu; all other providers drill into their
detail panel. Row actions and the footer links route to Settings.
Also: barColor's <60% band goes green -> neutral so color is reserved for
approaching limits (threshold color now lives only in the popover, the
always-visible bar stays monochrome), Codex plan_type is surfaced as
ProviderRateLimits.planType for the "Codex · Plus" label, and the Fable
weekly window is labeled "Fable" so it no longer collides with "wk".
* fix(status-bar): single-line sign-in row + review dedup
Signed-out roster rows now read as one line (name · "not signed in" ·
right-aligned Sign in) instead of a floating button over an orphaned
second line. Review follow-ups: the non-submenu ProviderDetailsMenu
branch reuses the extracted panelBody, and the icon-only letter badge is
one shared component so its has-data dot condition can't drift.
* fix(status-bar): harden usage roster interactions
* chore: remove unrelated formatting change
* chore(skills): refresh bundle manifest for rc.2
* fix(status-bar): preserve usage roster semantics
* fix(status-bar): preserve roster menu interactions
* chore: remove unrelated formatting changes
* fix(status-bar): keep usage reset countdowns live
* feat(status-bar): toggle compact usage summary
* fix(status-bar): simplify usage mode toggle
* feat(status-bar): replace usage footer toggle with Compact/Detailed segmented control
Swap the bottom-of-popover on/off switch for a SettingsSegmentedControl at
the top of the Usage popover (view-switcher pattern), so both modes are
named and discoverable on first open. Reuses the repo's canonical
Compact/Detailed vocabulary from the Workspace card-layout control.
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.
Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.
Area: main — core runtime, ipc, daemon, pty, providers. 73 files changed, 3206 insertions(+), 10475 deletions(-).
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): stop full durable-state save on every top-level view switch (#9002)
Persist activeView in a tiny profile-scoped sidecar instead of mutating the monolithic recovery snapshot. Active-view-only updates now bypass the broad UI normalization and durable save scheduler, while a 100ms atomic writer coalesces rapid switches and a synchronous shutdown checkpoint closes the immediate-exit race. Legacy state remains a migration and downgrade fallback.
Coordinate renderer shutdown capture through one guarded checkpoint so workspace sessions and the active-view preference both survive graceful reloads, restarts, and quit cancellation.
Add a persistence-boundary test proving the sidecar stays below 64 bytes while orca-data.json remains byte-for-byte unchanged, plus repeated Windows Electron restart coverage and a path-normalization-safe restart fixture.
* harden active-view sidecar: prototype-safe validator, race-free async swap, independent shutdown flush
- isTopLevelView uses Object.hasOwn so a corrupt sidecar can't smuggle
inherited keys (constructor/__proto__) through as a valid view.
- writeAsync guards the generation check and rename synchronously (renameSync)
so a shutdown flushOrThrow can no longer interleave and let a stale async
rename clobber the freshly-written view.
- shutdown checkpoint flushes the durable store and the active-view sidecar in
independent try/catch blocks so one store's failure can't skip the other.
Added regression tests for all three.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* feat(keybindings): swap tab-switch chords to the common convention for new users
New installs now get the widespread mapping — Mod+Shift+[ / ] cycles across
all tabs, Mod+Alt+[ / ] cycles within the active tab type. Pre-existing installs
keep today's mapping: a one-time cohort seed (frozen on first launch via the
fileExistedOnLoad signal, mirroring the telemetry migration) pins the legacy
chords into keybindings.json, skipping any action the user already customized.
- shared registry: swap the four tab.*SameType / tab.*AllTypes defaults; export
LEGACY_TAB_SWITCH_BINDINGS for the seed
- persistence: migrateTabSwitchKeybindings freezes the existing-vs-fresh cohort
(tabSwitchKeybindingSeed = pending | done)
- keybinding-file: seedLegacyTabSwitchBindings writes the legacy pins into the
active-platform section so Settings reset still works
- refresh stale default-chord comments
- tests for the swapped defaults, the seed (fresh/existing/customized/idempotent),
and the cohort migration
* test(keybindings): prove existing-user parity + make the seed strictly per-action
seedLegacyTabSwitchBindings now pins each un-customized action individually
instead of skipping all four when any one is customized. A partially-customized
existing user keeps their rebound action AND the pre-swap default on the rest;
no existing user's behavior changes. The skip check keys on this platform's
effective overrides so a foreign-platform-only override can't leave the active
platform on a new default.
Adds keybinding-service.test.ts: constructs a real KeybindingService and asserts
effective bindings + real keystroke matching for both cohorts across darwin/
linux/win32, plus partial-customization, idempotency, and seed-failure retry.
* fix(keybindings): preserve legacy files during tab shortcut seed
* fix(keybindings): preserve valid pre-swap overrides
* Add native macOS menu bar status item with settings toggle
Extend the Windows-only system tray into a shared status-item module
so macOS gets a template menu bar icon (Open/Settings/Check for
Updates/Quit), a theme-aware attention dot, and a "Show Menu Bar
Icon" appearance setting. Also fixes a startup race where a tray
"Settings…" click could fire before the renderer's ui:openSettings
listener attached, by queuing a one-shot pending-open-settings intent
the renderer consumes on mount.
* Fix Retina blur, race conditions, and menu-label duplication in tray Set
- Rebuild the tray attention icon's @2x representation since toBitmap only
read 1x pixels, blurring the glyph on Retina displays
- Fix premultiplied-alpha math so light-glyph tinting uses per-pixel alpha
instead of a flat 0xff, keeping antialiased edges valid
- Always push ui:openSettings and leave a longer-lived pending flag, since
there was no reliable signal that a renderer's listener was attached
- Preserve tray attention state across macOS menu-bar hide/show toggles
instead of resetting it on tray destroy
- Route macOS tray creation through syncMacMenuBarIcon so startup and the
live toggle share one visibility policy
- Reuse app-menu translation keys for tray Settings/Check for Updates and
drop the now-duplicate tray-scoped locale strings
* Make menu bar icon default on and platform-independent in settings
Previously showMenuBarIcon was hardcoded to darwin-only in both the
default settings and the sanitize/load paths, so a profile written on
macOS lost its opt-out when touched from another OS. Store the raw
boolean everywhere and let darwin-specific consumers decide whether
to act on it, so the value round-trips unchanged across platforms.
* Fix Settings menu click being silently dropped after a slow cold rendere
Replace the 60s pendingOpenSettings TTL with an untimed intent, since a
cold renderer start can outrun any fixed timeout and cause the flag to
expire before the click is consumed. webContents-id scoping plus
consume-on-read still prevent the intent from leaking to an unrelated
renderer. Adds a test covering the queued-before-mount pull path.