"Keep old account" cleared the renderer notice but left the on-disk launch
record, so the startup sweep re-raised the same prompt — and re-blocked that
pane's terminal input — on every subsequent launch. Dismissing now drops the
record through a new codexAccounts:forgetStalePanes handler, matching the
registry's own rule that an unrecorded PTY is never reported.
Also stubs both stale-pane methods in the web preload shim, whose `as never`
cast hid the gap; the sweep rejected with a TypeError there instead of
reporting no stale panes.
The pending-restart effect keyed off ptyIdsByTabId, which a restored pane
already lists before it ever mounts, so an unmounted pane's queued restart
never drained. Key off the pane->PTY layout bindings, which bindActivePanePty
and the reattach path both rewrite when a PTY actually binds.
pty:spawn re-records a pane's launch account on every daemon reattach, but a
reattached shell keeps the CODEX_HOME baked in at its original spawn. Recording
the current selection there erased the only evidence the pane was stale, so the
startup sweep found nothing to prompt about. Record after the spawn resolves and
skip reattaches.
Fixes#10757. A managed Codex account launches against its own
self-contained CODEX_HOME, which broke three things at once.
**History disappears (`/resume` is empty).** Codex's own resume picker only
lists rollouts under the launch CODEX_HOME, and nothing bridged history into
a per-account home — only the AI Vault's discovery scan knew about the other
homes. Every other Orca-visible home's rollouts are now hardlinked in, on
selection and again at launch, so one physical log is listed everywhere.
**The restart panel sticks and "nothing happens".** A queued restart is only
executed by a mounted TerminalPane, but the prompt covers every stale pane in
the worktree — including tabs that are parked, cold-deferred, or otherwise
unmounted. Those never drained, so the prompt returned with a button that now
did nothing. Requesting a restart now answers the prompt immediately while the
pane keeps its pending restart, and a pane drains that request when its
reconnected PTY binds. The notice record survives so A -> B -> A still
collapses.
**Stuck on the previous account after a restart.** CODEX_HOME is fixed in a
shell's environment at spawn, and the daemon keeps those shells alive across
app restarts while the restart notices (renderer state) are discarded — so
reopening Codex silently reused the old account with no way to escape. Each
PTY's launch account is now recorded on disk and compared against the current
selection at startup, which re-raises the prompt the app restart dropped.
Unrecorded PTYs are never reported: a wrong prompt is worse than none.
codex-home-paths.ts crossed its max-lines budget, so the resource copy-marker
helpers moved to their own module rather than taking a suppression.
* fix(github): resolve owner/repo through SSH Host aliases (#10284)
Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.
* fix(github): harden SSH alias resolution
* fix(github): align PR source and review head origin
* fix(github): pin number-based work item open to the repo source preference
Open-by-number and details still ran the upstream-first multi-candidate PR
probe, so a fork and its upstream sharing a PR number opened different PRs
than the list and start-point paths did once #10677 pinned those to origin.
Thread repo.issueSourcePreference through dispatchWorkItem, getWorkItemDetails,
getRepoWorkItem, and getRepoWorkItemDetails. getWorkItemByOwnerRepo is left
alone: explicit owner/repo already pins identity. auto/upstream/undefined keep
the multi-candidate probe.
Co-authored-by: Orca <help@stably.ai>
* test(github): enforce origin preference in review head origin resolution
The explicit origin preference must short-circuit before any identity probe, so no remote queries should occur. Add validation to reject unexpected remotes and tighten the test assertion to verify no remote get-url calls happen at all.
* fix(github): enforce origin preference in issue open-by-number lookup
listWorkItems and getWorkItem must share preference so origin/upstream
toggles cannot disagree. Explicit origin preference now fail-closes when
origin identity is unresolved (no bare-lookup fallback), matching the
PR candidate resolution rule.
---------
Co-authored-by: Orca <help@stably.ai>
* feat(source-control-ai): add {linkedIssue} recipe variable for commit and PR prompts
Custom commit-message and pull-request recipes can now reference the GitHub
issue linked to the workspace, so a template like "Fixes #{linkedIssue}" lands
the closing trailer without the user retyping the number.
- register `linkedIssue` on the commitMessage and pullRequest actions only,
with the VARIABLE_INFO entry the chip hover card requires
- substitute unconditionally via `formatLinkedIssueTemplateValue` (empty string
when nothing resolves) so the token never survives into a prompt; enrich the
draft context conditionally via `withLinkedIssueDraftContext` so unlinked
workspaces keep their existing context shape
- attach at the 7 call boundaries (runtime commit x2, runtime PR shared, IPC
commit x2, IPC PR x2); the pure git gather stays pure
- validate the renderer-supplied worktreeId against the request path and repoId
before any meta read, comparing SSH paths as raw strings so a Windows host
cannot rewrite a remote POSIX path
- built-in prompts are unchanged; no GitLab dual-read and no default trailer
* fix(source-control-ai): resolve {linkedIssue} adversarial review findings
Addresses 13 of the 14 findings from the {linkedIssue} code review
(6 minor, 8 nit, 0 critical, 0 major); Issue 5 (GitLab provider naming)
is deferred to design Open Question 3 as product expansion.
Behavior:
- Dialog previews the workspace's real linked issue instead of the
synthetic 123, in both the chip hover card and the plan preview, so an
unlinked workspace previews the `Fixes #` it will actually generate.
Settings dry-runs stay fully synthetic.
- Reject non-positive, fractional and unsafe-integer issue numbers at the
IPC resolver via a shared isLinkedIssueNumber predicate, so corrupt meta
never reaches a draft context (previously -7 rendered `Fixes #-7` and
1e21 rendered `Fixes #1e+21`).
- Fail closed on an empty-string repoId instead of skipping the cross-check.
Structure:
- Split the variable registry into source-control-ai-action-variables.ts
and re-export it, restoring max-lines headroom with no consumer churn
and no lint disable.
- Constrain withLinkedIssueDraftContext to contexts declaring linkedIssue.
- Move the misplaced shared imports into their import group.
Docs and tests:
- Document that the IPC id/path validator guards relay/CLI/future callers,
not the renderer (whose path is id-derived), and rename the three tests
that read as proof of a protection that cannot fire.
- Add PR-side coverage that was missing: three git:generatePullRequestFields
handler tests, a built-in PR prompt no-leak guard, and the runtime PR
unlinked case.
- Replace the coincidental '42' assertion with a fixture-unique sentinel.
- Type the runtime worktree fixture with satisfies, which surfaced and
fixed pre-existing drift in its git sub-object.
- Add an e2e case covering the preload -> main -> meta -> template chain.
Co-authored-by: Orca <help@stably.ai>
* fix(source-control-ai): resolve {linkedIssue} adversarial re-review findings
Addresses all 8 findings from the {linkedIssue} code re-review
(2 minor, 6 nit, 0 critical, 0 major); none deferred.
Behavior:
- Revert the variableOverrides parameter on planSourceControlTextGeneration.
Its result is a Save/Generate gate, not a preview, and the recipe it
validates is saved repo- or globally scoped -- so rendering it against the
active workspace disabled both buttons with "Command input is empty." for a
{linkedIssue}-only template on any unlinked workspace, blocking a global
settings write. Validation is synthetic again; chip previews are unchanged.
- Make the chip hover card additive instead of either/or. A supplied preview
now appends a "This workspace" sample below the description and Example
rather than replacing them, so the GitLab-empty and dangling `Fixes #`
warning survives on the two dialogs where recipes are actually authored.
basePrompt keeps its preview-only shape, where the preview is the content.
Structure:
- Drop the registry re-export from source-control-ai-actions.ts and move the
last two consumers onto source-control-ai-action-variables, so one import
path per symbol keeps a grep of the registry's consumers complete.
- Split the registry/helper suites into source-control-ai-action-variables.test.ts
so each test file mirrors its module.
Tests:
- Cover the Save/Generate gate at the canRunGeneration level for a bare
{linkedIssue} recipe on linked and unlinked workspaces, with a negative
control proving the buttons can still be disabled.
- Cover the chip hover card directly; the dialog tests mock it away.
- Guard the PR mismatched-id test with toHaveLength(1) so it cannot pass
vacuously on an unrelated early return.
- Add an unlinked-workspace e2e case (saw-issue:empty), which is what
distinguishes a real resolver from one that always returns a number.
Spec now runs green: 3 passed.
- Rename the dialog test that claimed a synthetic-fallback assertion it did
not make, and route its renders through one shared helper.
Docs are worktree-local (.gitignore:84 ignores docs/**): the design doc's
plan-preview and chip-surface claims, the manual QA rows, and both reviews'
statements about pre-existing PR-handler tests are corrected there.
* fix(source-control-ai): make the {linkedIssue} e2e guard and dialog test falsifiable
The e2e unlinked case extracted the echoed issue with `ORCA_E2E_ISSUE=(\d*)`,
which matches zero digits in front of an unexpanded `{linkedIssue}` and reported
it as `empty` — so the case that exists to catch a literal token surviving into
a prompt passed on exactly that regression. Capture the whole line instead: a
literal now arrives as `saw-issue:{linkedIssue}` and fails, verified by dropping
the substitution key for unlinked contexts and watching the case go red.
Also drop the inert `not.toContain('Command input is empty.')` assertion — that
copy is click-driven `generationError` state and this suite renders statically,
so it could never fail; the claim it reached for is carried by the plan test.
Rename two plan tests off the "plan preview" framing the design now rejects.
Local review artifacts (design doc, implementation notes, final review) were
swept to match the tree in the same pass; they are gitignored here.
* Resolve {linkedIssue} from live metadata, not cache
Resolved worktrees are cached for a second, causing commit and PR
generation to use stale linked-issue state. Hosts now implement
getWorktreeLinkedIssue to provide fresh issue metadata by worktree id,
with proper fallback for unlinked workspaces. Updates both commit
message and PR field generation paths; includes integration and e2e
coverage.
* Keep cached linkedIssue when metadata is unavailable
Return undefined from getWorktreeLinkedIssue when live metadata cannot be read
(store not ready), distinguishing it from null (unlinked). The caller now falls
back to the cached worktree value instead of treating unavailable as unlinked.
Also extract the linked-issue echo generator as a shared e2e test helper.
---------
Co-authored-by: Orca <help@stably.ai>
Preserve object identity in pane-title overlay rect state to fix infinite
re-renders. A fresh {} literal creates a new reference on every call, causing
React to treat the state as changed even when logically equivalent, triggering
the layout effect to continuously re-measure and re-set state (React #185).
Add cold-park verdict flip telemetry to diagnose crash cluster C5: records
whether parking state churns in the field for next crash bundle analysis.
Document that AddressPicker crash (cluster C6) originates in radix-ui's
SelectItem unmount cleanup, not in our component code.
* fix(worktree): collapse duplicate "Local Mac" run targets in the host picker
A linked worktree added as its own project projects a second ready host
setup on the same project+host, so the run-target picker rendered N
identical "Local Mac" rows differing only by path. Only the first was
reachable — resolveWorkspaceCreationTarget takes the first project+host
match — so the extras pointed at paths that may no longer exist.
- Dedupe ready setup options by host in the picker (display fix for
profiles that already hold duplicates).
- Canonicalize a stale draft's setup id to the setup the picker shows,
so the displayed path is the path the workspace is created in.
- Reject a linked worktree at repos:add when its main checkout is
already tracked, preventing new duplicates.
* fix(worktree): only dedupe a linked worktree against a git main checkout
Review follow-up: the repos:add guard matched any tracked repo on the main
checkout path, including a folder-kind record. A folder repo does not
project onto the same project as the git worktree, so matching it would
suppress a legitimate add without deduping anything.
* fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze creation
`.worktreeinclude` copying was bounded in entry count (1000) but unbounded in
bytes and files, and awaited inline during worktree creation. A repo listing
`node_modules` froze creation for minutes behind the create dialog on Linux and
Windows, where the fallback is a full `fs.cp` (macOS gets a cheap APFS clone).
Measure each copy-mode source against a cumulative budget (2 GB / 50k files)
before the first byte is written, and refuse the entries that bust it. Refused
entries ride the existing `CreateWorktreeResult.warning` channel so a workspace
never silently comes up missing its included files.
Pre-measurement rather than mid-copy abort: `fs.cp` ignores its `signal`
option, so a started copy cannot be cancelled and would strand a partial tree.
Refusing up front means there is no partial state to clean up.
* fix(worktree): don't charge bytes for copy-on-write clones, and bound the sizing walk
Two defects in the copy budget, both found by review:
- The byte limit was applied on macOS, where the copy is an APFS clonefile.
Measured: a 2.7 GB tree clones in 22 ms and consumes no disk. Refusing it on
a 2 GB byte ceiling denied work that was already free — a regression on the
one platform this bound was never meant to touch. Bytes are now charged only
when a byte-for-byte copy will actually run; the volume probe that decides
this is the same cached df+diskutil pair the clone runs, and writes nothing,
so the "refuse before the first byte" invariant holds. The entry limit still
applies everywhere: inodes are real work even on the clone path.
- A refused entry consumed no budget, so a `.worktreeinclude` listing many
over-budget directories paid a fresh full-limit walk for each one — up to
1000 x 50,000 lstat calls, re-creating the stall this bounds. The walk is now
charged against its own ceiling whatever the verdict.
Also documents that `admit()` must be awaited sequentially (CodeRabbit).
* fix(worktree): give the sizing walk headroom so one huge entry can't starve the rest
The walk ceiling added in the previous commit was seeded with maxEntries, the
same number the entry limit uses. Sizing an entry that busts the file-count
limit walks maxEntries + 1, driving the ceiling negative, so every later
`.worktreeinclude` entry was refused without being measured at all.
That regressed the common case: a repo listing `node_modules` plus `.env` used
to get `.env`; it silently got nothing. Reproduced, and now covered by a test
that fails when the headroom is removed.
The walk now gets 5x the entry budget, so total sizing work stays bounded
(<=250k lstat per materialization, vs the 1000 x 50k this ceiling exists to
prevent) while ordinary lists never reach it. Entries refused because earlier
ones exhausted the walk report a distinct 'sizing' reason, so the warning stops
quoting size limits at a 4-byte file that was never measured.
* fix(worktree): bill a failed clone's bytes, and blame the right ceiling
Two follow-on defects from the copy-on-write fix:
- A predicted APFS clone that then failed mid-copy (EPERM, ENOSPC) fell through
to a real `fs.cp` whose bytes were never charged, because the entry had been
admitted on the premise that cloning is free. That reopened the unbounded
copy on macOS. The measured size is already known, so the fallback now bills
it and refuses if it no longer fits, reporting the entry as skipped instead
of silently copying gigabytes. A clone that was never viable
(ApfsCloneUnavailableError) was already charged as a real copy, so that path
keeps falling back as before.
- The walk ceiling is also applied inside the measurement via
min(remainingEntries, remainingWalk), and when the walk term bound, the
refusal was still reported as 'entries' — telling the user a 3-file directory
busted a 4-file limit. It now attributes to whichever ceiling actually bound.
Also fixes the singular warning text, which said "entry X was not copied ...
copying them would exceed ... Copy them in manually".
* fix(worktree): flag a partial clone leftover, cap the warning, cover two branches
- A clone that fails partway only removes an *empty* reservation, so leftovers
can survive at the target. Reporting that entry as simply "not copied" sent
the user to copy it in manually, straight into a half-populated directory.
Those skips now carry mayBePartial and the warning says to check the path
first. Cleaning up the leftovers stays the deferred follow-up it already was.
- The warning enumerated every skipped path. `.worktreeinclude` allows 1000
entries and all of them can be skipped, so it now names five and counts the
rest — an unbounded string is a poor look in a PR about bounds.
- Two load-bearing branches had no test, both proven by surviving mutants: the
`bytesAreCopied` short-circuit (reachable when a wedged df/diskutil makes the
volume probe answer "no clone", so bytes are charged up front and must not be
billed twice), and chargeBytes actually consuming budget for later entries.
* fix(worktree): only flag directory clones as partial, and cap that list too
- mayBePartial was set for every refused clone fallback, but only a *directory*
clone can leave anything behind: the file path clones into a temp name and
publishes with link(2), so a failure leaves nothing at the target. Sending
the user to inspect a path that does not exist is its own small lie.
- The partial-copy sentence sliced to five names without the "and N more" that
the other sentence appends, so entries past the fifth were surfaced nowhere.
Both sentences now share one nameList helper.
startGitCommonNarrowWatch was the only watch entry point that never received
WorktreePollerWindowVisibility, so its `worktrees/` existence poll kept
stat'ing every repo without a linked worktree forever in the background
(0.5 stat/sec/repo at the 2s default). Its siblings — the primary-metadata
snapshot poller and the non-darwin git-common polling — already park.
Threads visibility through and matches the snapshot poller's park/re-arm
pattern: the poll stops on the first hidden tick, and re-checks immediately on
onWindowBecameVisible so a worktrees dir created while hidden still upgrades to
the native stream and emits its create event. The visibility listener is
dropped in the dispose path.
darwin-only: the narrow watch is the `platform === 'darwin'` branch.
* fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL
OpenCode reports agent status via a JS plugin dropped into OPENCODE_CONFIG_DIR
(unlike Claude/Codex, which use managed hooks.json scripts). Over the WSL
runtime that plugin was never materialized inside the guest and
OPENCODE_CONFIG_DIR never crossed into the guest, so OpenCode status never
reached Orca's sidebar (the workspace stayed green). Codex already worked; this
was OpenCode-specific.
Mirror the SSH plugin-overlay path for WSL:
- Guest relay registers AGENT_HOOK_INSTALL_PLUGINS_METHOD, byte-caps the source,
and materializes an OpenCode config overlay via PluginOverlayManager (the same
electron-free path the SSH relay uses), returning overlayDirs.opencode.
- Host manager ships the plugin source over the existing stdio channel after
installers run (and on mid-session reinstall) and records the guest overlay
dir; -32601 / CONNECTION_LOST / DISPOSED are swallowed like ssh-relay-session.
- PTY env points OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR at the guest
overlay; until the relay reports it (first spawn / older guest bundle) it drops
those vars rather than crossing the Windows overlay path into WSL — so
in-guest OpenCode falls back to its own config (pre-fix behavior, no
regression).
- WSLENV passes OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR through (/u for
guest paths).
SSH is untouched: the same JSON-RPC constant is reused and the guest response
merely gains an optional overlayDirs field the SSH host ignores.
Runtime repro: native opencode launched in a WSL Orca terminal had
ORCA_AGENT_HOOK_PORT/ORCA_PANE_KEY but no OPENCODE_CONFIG_DIR and no Orca plugin
in ~/.config/opencode, so agentStatusByPaneKey stayed empty.
* fix(agent-hooks): stop the WSL OpenCode overlay leaking Windows paths and churning under running agents
Review fixes on top of the WSL OpenCode plugin install:
- Never cross a Windows OPENCODE_CONFIG_DIR into the guest. The /p flag was
not a defensive default but WSLENV's translate-and-deliver flag, and
buildWslRelaySpawnEnv spreads process.env while the daemon merge resurrects
keys buildPtyHostEnv only deleted -- so a Windows value reached the guest as
/mnt/c/... and was adopted as its OpenCode config root. Register the two
vars only when the value is already a guest POSIX path.
- Make guest materialization idempotent. The overlay id is instance-scoped,
and the host re-ships on every reinstall (60s after connect, and on later
pane spawns), so materializeOpenCode's remove-and-rebuild wiped the config
root under running agents and raced panes spawning against the path just
handed to them. Rebuild only when the shipped source changed or the overlay
went missing.
- Mirror the guest's default ~/.config/opencode (honouring XDG_CONFIG_HOME)
when no explicit dir is discoverable, so pointing OPENCODE_CONFIG_DIR at the
overlay no longer silently drops the user's models/agents/skills/mcp.
- Carry opencodeOverlayDir across relay relaunch; it is instance-keyed and on
the distro's persistent filesystem, so dropping it only blanked status on
panes spawned mid-relaunch.
* fix(agent-hooks): stop advertising a WSL OpenCode overlay the guest failed to rebuild
Round-2 review fixes:
- materializeOpenCode wipes before rebuilding, and every failure path after the
wipe returns null leaving the dir present but plugin-less. The host treated
that null the same as "no handler / teardown" and silently kept the previous
value, so a pane could be pointed at an empty config root -- worse than the
documented fallback of dropping the var. requestGuestOpenCodeOverlayDir now
distinguishes 'none' (guest answered, no dir) from 'unavailable', and the
manager clears the recorded dir on 'none'.
- The handler cache keyed only on plugin source, so a ~/.config/opencode created
after the relay connected was never mirrored for the relay's lifetime. Key on
the resolved source dir too, and validate the cache by the plugin file rather
than the directory -- the directory is exactly what a failed rebuild leaves
behind, so checking it alone made the bad state stick.
* fix(agent-hooks): don't mirror the XDG default OpenCode config into the WSL overlay
OPENCODE_CONFIG_DIR is APPENDED to OpenCode's config-dir list, not a
replacement for it. Verified against the shipped binary: the list is built as
[Path.config, ...project .opencode dirs, ...OPENCODE_CONFIG_DIR ? [it] : []],
and Path.config is derived independently from XDG_CONFIG_HOME/$HOME/.config.
So ~/.config/opencode is read whether or not Orca overrides the var, and the
earlier fallback that mirrored it into the overlay made OpenCode load the
user's config -- and their plugins -- twice. Resolve only an explicitly-set
dir, which is the one case that genuinely leaves the list when Orca overwrites
the variable. This also restores parity with the SSH and local paths.
* docs(agent-hooks): correct the WSL install-plugins cache comment and test framing
The per-call source-dir re-resolution comment still described the XDG default
branch that 6745eba90 removed. The relay's env is fixed for its lifetime and
the rc scan behind it is memoized, so the sourceDir cache key is defensive
rather than live -- say so, and retitle the test that simulates it by mutating
the captured env, so neither reads as coverage of a production scenario.
* feat(codex): surface a stalled config sync instead of failing silently
Why: the mirror keeps serving the last synced settings when ~/.codex/config.toml
is missing, blank, or unreadable. That is the right call for data safety, but it
is invisible — a downed WSL distro or an unhydrated cloud-synced home leaves
"Orca ignores my config edits" with no log line and no UI to diagnose.
Status is derived on demand from the same predicates the mirror uses, so the two
cannot disagree. The stall is logged once per episode rather than on every launch
and quota poll, and the Codex account section names the file and what to do.
* fix(codex): latch an unreadable source and stop over-claiming recovery
An unreadable source throws out of the mirror, so reporting only on the success
path left that stall latch-less: it logged the raw failure on every launch and
quota poll while its reason never reached the surfaced status. Report from the
catch path too.
The clear message also claimed the source was "readable again", which is false
when the stall ended because the runtime config was removed rather than because
the source came back.
Restoring console.warn now happens in afterEach — an inline mockRestore is
skipped by a failing assertion, and the leaked spy made every later case in the
block fail spuriously.
* fix(codex): latch the stall promotion hits first, and scope it to the host
Review round 1 findings:
- The unreadable-source latch still never fired in the steady state. Once a
baseline exists, promotion reads the source before the mirror does, so it
throws first and `!promotionPlan` returned before any reporting — logging a
reasonless failure every launch and quota poll, which is exactly what the
previous commit claimed to fix. Report from that branch too. The test only
passed because its fixture had no baseline; it now seeds one first and fails
without the fix.
- The banner named the host's ~/.codex while a WSL or per-account runtime was
selected, whose real source is a different file entirely. Gate it to the host
scope, matching how the sign-in warning is already gated.
- Three new translate keys were missing from the locale catalogs, failing the
localization gate in `pnpm lint`.
- The registrar mock was never asserted, so deleting the registration left the
suite green.
- `codexConfigSyncStatus` hung off the `agentHooks` namespace despite having
nothing to do with agent hooks; moved to its own `codexConfigSync.status`
while it is still a four-file change.
* fix(codex): report sync health for the home the selection actually mirrors
Review round 2:
- The status resolved the shared runtime home, but the system default now runs
Codex directly against ~/.codex and managed accounts get their own home. So a
stalled per-account mirror showed no banner at all, while a stale shared home
could warn about a config the active lane never reads. Resolve the mirrored
home from the current selection, and report synced when the lane has no mirror
to fall behind.
- The round-1 report on the promotion failure path could clear the latch on a
pass where no mirror ran, claiming a recovery that never happened and
silencing every later pass. Only ever latch a stall there; leave clearing to
the path that actually mirrored.
* fix(codex): refetch sync status when the active Codex account changes
Review round 3:
- Resolving the status per selection made the fetch account-dependent, but the
effect was not keyed on the active account. Switching accounts left the banner
describing the previous one — and switching INTO a stalled account showed
nothing at all, which is the silence this change exists to remove.
- Pin the home resolution itself: it had no direct test, and its shared-home
path was a hand-copied literal that could drift from the real helper and
silence the banner with every other test still green.
- Narrow the handler's dependency to the one method it calls, which also drops
an `as unknown as` cast from its test.
- Skip the chmod-based test on Windows, where a read-only directory does not
block writes so the scenario cannot be constructed; matches the convention
already used in config-settings-promotion.test.ts.
* chore(codex): restore the handler docstring and isolate the resolver suite
Round 4 returned clean; these are its two non-blocking nits.
Narrowing the handler param left its JSDoc stranded above the new type, so the
function had no hover doc. The resolver suite also read the developer's real
CODEX_HOME and shell rc, so anyone exporting one would see it fail locally.
* fix(ssh): re-arm remote file watches when the provider reconnects
Remote file changes stopped being detected over SSH until the file was
reopened. The watch pipeline itself was fine — nothing ever re-established
the subscription after the transport it was made on went away.
Two paths left an editor tab permanently stale:
- A reconnect kills the relay's watch registrations, and the previous
provider's unwatch handle belongs to the dead transport.
- A connect slower than the 60s retry window made installRemoteWatcher
give up for good; first deploy to a new host far exceeds that.
Neither recovered, because installRemoteWatcher is only reachable from the
fs:watchWorktree handler, the retry timer, and the removal-restore path,
and the renderer only issues a watch for newly added targets. Reopening
the file just re-read it — the watcher stayed dead.
Give the layer that owns the transport the job of re-arming:
registerSshFilesystemProvider now notifies subscribers, which covers both
establish and reconnect since registerProviders runs on both. The watcher
keeps the intent to watch in a registry that outlives any single
connection, and on registration drops the stale entry (installRemoteWatcher
treats an existing entry as installed and would otherwise hand back a
watcher that can never fire), reinstalls, and emits overflow so consumers
resync the gap. Intent is dropped on unwatch and sender destroy so a
closed tab is never resurrected.
Verified over SSH to a Rocky Linux 10 host: after the reconnect that
previously killed it, a remote append lands in the editor in ~2s with a
live remote watcher process, and a second edit in ~1.5s.
* fix(ssh): drop watch intent when a remote worktree is removed
A removed worktree kept its entry in the intent registry, so a reconnect
landing before the renderer's unwatch would re-watch a deleted path —
60s of retries against the host and then a bogus overflow.
Also covers two reinstall cases: several senders on one connection must
collapse onto a single relay watch (and all of them resync), and a
destroyed renderer must not be reinstalled.
* fix(ssh): resync when a reconnect's watch only lands on a retry
The reinstall emitted the overflow only for listeners whose first install
returned 'installed'. If that attempt failed — relay-watcher.js still
spawning on the fresh transport, a transient fs.watch rejection — the 1s
retry restored the watch but never signalled the gap, so everything that
changed while the transport was down stayed invisible: the STA-2525
symptom reappearing inside the fix for it.
Thread the resync intent through the retry record so the overflow lands
when the retry does. All pre-existing callers default to false, so the
watch/terminal-error/restore paths are unchanged.
* test(ssh): cover the resync merge when a fresh watch claims the retry slot
The `resyncOnInstall ||=` merge was uncovered: removing it left every
existing test green. It is load-bearing — if a second renderer joins the
reinstall's failing install and reaches the retry slot first with
resync=false, the whole chain stays false and the retry restores the
watch without ever signalling the gap.
New sibling file rather than an append: filesystem-watcher.test.ts is
~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a
max-lines disable.
* fix(ssh): re-arm a remote watch that died without a reconnect
The 60s fast-retry window gave up with a single overflow and no further
trigger — provider registration is the only re-arm, and a watch killed by
remote OOM/inotify exhaustion leaves the SSH link perfectly healthy. Back
off from 1min to a 30min ceiling instead, and stand down entirely when the
provider is gone so registration owns that case.
* fix(ssh): re-arm the remote file-explorer watch on reconnect
SshFilesystemProvider.dispose() stops each watch registration without
invoking its terminal callbacks, so a dropped transport left the runtime
file-explorer watch silently dead: the lease's restart only runs from the
failed-removal path, and the renderer's runtime subscription stays open
because it rides a different link. Reinstall on the connection's next
provider registration and emit overflow so clients resync.
* fix(ssh): re-arm remote file watches when the provider reconnects
Remote file changes stopped being detected over SSH until the file was
reopened. The watch pipeline itself was fine — nothing ever re-established
the subscription after the transport it was made on went away.
Two paths left an editor tab permanently stale:
- A reconnect kills the relay's watch registrations, and the previous
provider's unwatch handle belongs to the dead transport.
- A connect slower than the 60s retry window made installRemoteWatcher
give up for good; first deploy to a new host far exceeds that.
Neither recovered, because installRemoteWatcher is only reachable from the
fs:watchWorktree handler, the retry timer, and the removal-restore path,
and the renderer only issues a watch for newly added targets. Reopening
the file just re-read it — the watcher stayed dead.
Give the layer that owns the transport the job of re-arming:
registerSshFilesystemProvider now notifies subscribers, which covers both
establish and reconnect since registerProviders runs on both. The watcher
keeps the intent to watch in a registry that outlives any single
connection, and on registration drops the stale entry (installRemoteWatcher
treats an existing entry as installed and would otherwise hand back a
watcher that can never fire), reinstalls, and emits overflow so consumers
resync the gap. Intent is dropped on unwatch and sender destroy so a
closed tab is never resurrected.
Verified over SSH to a Rocky Linux 10 host: after the reconnect that
previously killed it, a remote append lands in the editor in ~2s with a
live remote watcher process, and a second edit in ~1.5s.
* fix(ssh): drop watch intent when a remote worktree is removed
A removed worktree kept its entry in the intent registry, so a reconnect
landing before the renderer's unwatch would re-watch a deleted path —
60s of retries against the host and then a bogus overflow.
Also covers two reinstall cases: several senders on one connection must
collapse onto a single relay watch (and all of them resync), and a
destroyed renderer must not be reinstalled.
* fix(ssh): resync when a reconnect's watch only lands on a retry
The reinstall emitted the overflow only for listeners whose first install
returned 'installed'. If that attempt failed — relay-watcher.js still
spawning on the fresh transport, a transient fs.watch rejection — the 1s
retry restored the watch but never signalled the gap, so everything that
changed while the transport was down stayed invisible: the STA-2525
symptom reappearing inside the fix for it.
Thread the resync intent through the retry record so the overflow lands
when the retry does. All pre-existing callers default to false, so the
watch/terminal-error/restore paths are unchanged.
* test(ssh): cover the resync merge when a fresh watch claims the retry slot
The `resyncOnInstall ||=` merge was uncovered: removing it left every
existing test green. It is load-bearing — if a second renderer joins the
reinstall's failing install and reaches the retry slot first with
resync=false, the whole chain stays false and the retry restores the
watch without ever signalling the gap.
New sibling file rather than an append: filesystem-watcher.test.ts is
~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a
max-lines disable.
* feat(worktrees): copy project-level .worktreeinclude paths into new worktrees
Read .worktreeinclude at the repo root (gitignore syntax) and copy matching
gitignored paths from the primary checkout into each newly created local
worktree, so .env and other local config carry over with zero per-user setup.
- Literal patterns resolve by direct stat; globs match against
ls-files --others --ignored --exclude-standard --directory (collapsed
dirs keep huge repos fast); every candidate is re-verified with
check-ignore so tracked or unignored files are never copied.
- Copy semantics, never symlink: APFS clone-copy on macOS, real copy
elsewhere, so each worktree owns its files (unlike repo.symlinkPaths,
which it merges with rather than replaces).
- Failures never block worktree creation.
- Remote (SSH) creation skips it, same as symlinkPaths.
- Split APFS clone helpers into worktree-apfs-clone.ts (max-lines).
Closes#7549
* fix(worktrees): harden worktree include copying
* fix(worktrees): support nested includes on Git 2.25
* fix(worktrees): bound include copy costs
* fix(worktrees): close include correctness and perf gaps
* fix(worktrees): preserve included copy semantics
* fix(worktrees): harden include resolution
* fix(worktrees): preserve bounded include resolution
* fix(worktrees): bound include filesystem resolution
* fix(worktrees): harden include matching
* fix(worktrees): tighten include matching and scan bounds
* fix(types): use concrete filesystem stat types
* fix(worktrees): harden included path materialization
* perf(worktrees): stop include parsing at resolver budgets
* chore(skills): refresh bundled skill manifests
* refactor(worktrees): reduce .worktreeinclude to focused literal-only scope
The reviewed implementation grew well past the ticket (#7549), which asks for a
size-M feature that reuses existing worktree machinery. Trim back to the minimal
change that solves the reported problem safely:
- Resolver now supports literal files and directories only. Glob/negation lines
are skipped with a warning (documented follow-up), which removes the entire
user-controlled-regex ReDoS surface, the CPU/byte budgets, the git enumeration
scan, and the case-sensitivity engine. The filesystem + git check-ignore
handle existence and case for free.
- Copy layer folded back into worktree-symlinks.ts (link/copy modes share one
loop); dropped worktree-path-copy.ts, worktree-target-safety.ts, the
descendant-dedup/realpath/target-parent machinery, and the per-materialization
APFS filesystem cache. Kept the df/diskutil probe timeout.
- Reverted unrelated changes: check-ignored-paths timeout param and the
git-binary-compatibility enumeration tests.
Net: -1903/+172 across the include+copy code. Behavior for the ticket's cases
(.env, .env.local, .vscode/, node_modules, config/secrets.json) is unchanged;
gitignored-only + copy-not-symlink semantics preserved.
Closes#7549
* fix(worktrees): dereference symlinked .worktreeinclude entries + cache APFS volume probe
Two issues found by review + perf audit of the copy path:
- Correctness (HIGH): a listed entry that is itself a gitignored symlink was
copied AS a symlink (fs.cp dereference:false), and the darwin APFS branch was
skipped for all symlink sources. Editing the worktree's copy then wrote through
to the shared/primary target — inverting copy-mode's 'each worktree owns its
files' guarantee, and escaping the worktree entirely if the link pointed
outside it. Now resolve realpath for a top-level symlink in copy mode so we
copy content; nested symlinks inside a copied dir stay as-is (cp -R semantics).
- Perf: assertSameApfsVolume ran df+diskutil per copied path (4 subprocesses
each), so an N-entry include spawned ~4N short-lived processes on the macOS
create hot path, all re-probing one volume. Add a per-materialization
device-keyed cache: one probe per distinct volume (4N -> ~4).
Tests: symlinked-file and symlinked-dir dereference regressions (no leak to
primary); APFS volume probed once regardless of copied-path count.
* Fix fork PR/MR worktree creation race via durable review-head refs
When creating a fork PR/MR worktree, concurrent `git fetch origin` operations
clobber the shared FETCH_HEAD, causing the wrong commit to be checked out.
Fetch PR/MR heads into dedicated per-review refs (`refs/orca/pull/<N>`,
`refs/orca/merge-requests/<N>`) that persist and isolate each head from other
fetches. Gracefully keep the compare-base when the fetch fails but the local
ref already exists, avoiding silent fallback to the wrong branch on transient
network errors.
* Bound PR/MR head fetches with 60s timeout
Prevent PR/MR creation from hanging when a remote is stalled or
unreachable. Both GitHub and GitLab head fetches now enforce a
60-second timeout, matching the bound used in the create-path
fetch. Durable refs (refs/orca/pull/*, refs/orca/merge-requests/*)
decouple the ref from FETCH_HEAD, preserving legacy client semantics.
* test: align CI expectations with main PowerShell/sparse regressions
PR checks merge into main, which recently changed PowerShell launch args
(cwd restore after profiles) and sparse-checkout detection (require
core.sparseCheckout). Derive PowerShell spawn args from the production
resolver, mock the sparse config flag, reset shared worktree list scan
cache between tests, and stop requiring floating polls to avoid getRepos
hydration.
* Address review follow-ups on durable review-head refs
- Unify PR review-head remote selection: local and SSH GitHub paths share
resolveGitHubReviewHeadRemote, which prefers the remote mapping to the
hosting GitHub project (upstream before origin, matching work-item/API
candidate order) so contributor clones fetch refs/pull from the repo
that actually hosts the PR.
- Soft-keep durable review heads: when the PR/MR head fetch fails but
refs/orca/pull/<N> / refs/orca/merge-requests/<iid> still resolves,
keep the pinned SHA (warn) instead of failing resolve, mirroring the
compare-base fallback. Extracted shared compare-base soft-keep into
compare-base-ref-fetch.ts.
- Extract fetchGitLabMergeRequestHeadRef (local + SSH) parallel to the
GitHub helper; bound its local fetch with the shared 60s timeout.
- Share relay-style fetch validation (positive safe-integer id, remote
not starting with "-") between relay and local helpers via
review-head-tracking-ref.ts; move REVIEW_HEAD_FETCH_TIMEOUT_MS there.
- Drop the githubPullRequestHeadLocalRef re-export; resolve head SHAs via
rev-parse --verify <ref>^{commit}.
- Add GitLab anti-FETCH_HEAD regression test plus durable-head soft-keep
and remote-selection unit tests.
Co-authored-by: Orca <help@stably.ai>
* test: supply live getRepos for terminal-retirement hydrates
Main's headless tab hydrate (#9343) skips worktree keys whose repo is not
in getRepos. Retirement tests that rebuild mobile tabs from a persisted
session now advertise the fixture repo as live so PR Checks merge stays green.
* fix(editor): extract RichMarkdownEditor props to stay under max-lines
Main's SSH external-image wiring (#10323) pushed RichMarkdownEditor.tsx over
the 400-line tsx budget, failing PR Checks lint on every merge into main.
Move the props type into a sibling module so the component stays under the
limit without disabling max-lines.
* Make durable review-head refs remote-identity scoped
Embed remote name + URL hash into refs/orca/pull|merge-requests refs to prevent soft-keep from serving wrong project's PR/MR when FETCH_HEAD is clobbered by concurrent fetch. Fetch functions now return the written ref path (writer-authoritative) so callers rev-parse exactly what was fetched, not re-derive identity. Soft-keep only applies to transient errors (timeout, network); fails hard on missing refs, auth failures, and stale relay. Relay returns localRef so client avoids re-hashing (URL normalization can disagree).
---------
Co-authored-by: Orca <help@stably.ai>
* feat(agent-dashboard): choose in-window screen popover or pop-out window
The experimental Agent Dashboard opened only as a separate pop-out
window. Add an "Open as" mode under the experimental toggle so it can
open as an in-window screen popover (new default) or a pop-out window
(prior behavior). The mode row appears only when the feature is on.
- New setting `experimentalAgentDashboardMode: 'in-window' | 'popout'`
(default in-window); sidebar entry branches on it.
- In-window: AgentDashboardOverlay renders the shared AgentKanbanBoard
in a near-fullscreen dialog, snapshot built locally via
useLiveDashboardSnapshot (the pop-out relays over IPC; in-window has
no relay). Ack/reveal act on the local store — the pop-out IPC
handlers are gated to the pop-out renderer.
- AgentKanbanBoard gains containerClassName/onAckAgent/onRevealAgent/
onClose props; defaults preserve the pop-out behavior.
- Extracted AgentDashboardExperimentalSetting to keep ExperimentalPane
under the max-lines cap.
* fix(agent-dashboard): admit main renderer to terminal-preview IPC for in-window dialog
The terminalPreview:* handlers gated every channel to the pop-out
renderer, so the in-window overlay's terminal dialog (running in the
main renderer) got { snapshot: null } from connect and falsely showed
"No live terminal — this agent's pane has closed." for live agents.
Accept the trusted UI renderer too — it already has full PTY access
through the regular terminal channels, so this adds no reach.
* fix(agent-dashboard): sync locale catalogs for new mode/close keys
verify:localization-catalog (part of lint CI) fails when en.json keys are
missing from the other locale catalogs; run sync:localization-catalog so
the six new agent-dashboard keys exist everywhere (English fallback text;
translated copy remains the documented follow-up).
* feat(agent-dashboard): present in-window mode as a companion board sheet
The in-window dashboard now uses the same non-modal left sheet as the
workspace kanban board — anchored to the sidebar edge, chrome/status-bar
bounds, sidebar stays interactive — instead of a near-fullscreen modal
dialog. Both companion boards are mutually exclusive; the sidebar entry
toggles the drawer. Removes the modal focus-restore timing coupling on
reveal.
* fix(agent-dashboard): ignore Radix dismiss requests like the workspace board
Non-modal Radix layers also request dismissal for interactions the drawer's
outside guards cannot classify — focus moving outside carries no pointer
coordinates, and clicks in the status bar / top chrome fall outside the
right-side dismiss band. Forward only open requests from the Sheet, matching
WorkspaceKanbanDrawer, so only the drawer's own escape/outside/close paths
close it.
* fix(agent-dashboard): guard reveal relay and refresh stale mode copy
CodeRabbit review: revealAgent lacked the ?. HMR-skew guard its sibling
ackAgent has (both channels shipped together, so a stale dev preload
lacks both). The es/ja/ko/zh catalogs also still described the dashboard
as pop-out-only in stale English, contradicting the new in-window
default; refreshed to the current English source.
* feat(agent-dashboard): add board settings menu to the in-window header
Mirrors the workspace board's settings gear: an Open as segmented control
in the board header so the mode is changeable without opening Settings.
Switching to pop-out hands the surface over (closes the drawer, opens the
window) instead of leaving a board the setting says should be a window.
In-window only via an optional headerActions slot - the pop-out renderer
has no store to drive it.
* fix(agent-dashboard): reset the settings-menu flag when the drawer closes
The pop-out hand-off closes the sheet while the menu is still open, so the
menu unmounts without Radix reporting onOpenChange(false). The stale
menuOpen=true then blocked outside-dismiss permanently on the next
in-window open. Mirror closeWorkspaceBoard by resetting the flag in close.
* fix(agent-dashboard): reset the menu flag on store-driven drawer closes
Cmd+B sidebar collapse and the workspace-board exclusivity effect close
the drawer via setAgentDashboardDrawerOpen directly, bypassing close();
a settings menu open at that moment unmounted without Radix reporting
onOpenChange(false), leaving menuOpen stuck true and outside-dismiss
disabled on the next open. Sync the flag to the open state so every
close path resets it.
* Normalize branch prefixes and flag invalid ones in settings
A custom branch prefix ending in a slash (e.g. "team/") produced a
double-slashed branch name like "team//feature" that git rejects, and
the raw check-ref-format error gave no hint that the prefix caused it.
- Normalize the configured prefix (trim whitespace, strip leading/
trailing and duplicate slashes) in the shared branch-name builder so
the common trailing-slash case just works, for local and SSH worktrees.
- Validate the prefix on the worktree-create path (computeValidatedBranchName)
so a genuinely invalid prefix fails fast with a clear
"update it in Settings -> Git" message instead of an opaque git error.
- Add a live BranchPrefixFeedback under the Branch Prefix setting: previews
the resulting branch name, warns on invalid characters, and notes when a
prefix collapses to none.
- Keep the background first-work rename on the non-throwing builder since the
prefix is already validated at create time.
* Keep caret in place when editing the branch prefix
The custom branch prefix input was directly controlled by settings, but
updateSettings persists through an async IPC round-trip, so the value
updated a tick late and React re-assigned it, snapping the caret to the
end on mid-string edits. Drive the input from a local draft and only
adopt genuine external settings changes so the caret stays put (and fast
typing survives slow SSH round-trips).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Return ReactNode from BranchPrefixFeedback
JSX.Element needlessly excludes null/string/number returns; ReactNode
keeps the component's return type from over-constraining future changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
`worktrees:persistSortOrder` (ipc/worktrees.ts) and `persistManagedWorktreeSortOrder`
(orca-runtime.ts) call `store.setWorktreeMeta(id, { sortOrder })` for every id the
renderer sends. `setWorktreeMeta` has no repo-existence check, so a stale id the
client still lists — e.g. a removed repo's `${repoId}::${path}` that lingers in the
renderer's order — gets a brand-new `worktreeMeta` entry minted on every sidebar
snapshot, resurrecting an orphan/duplicate workspace on the next launch.
A sort-order snapshot must only reorder worktrees that already exist, never create
one. Guard both call sites with `getWorktreeMeta` so absent ids are skipped.
Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(worktree): match created worktrees through symlink roots
On immutable Linux, /home is often a symlink to /var/home. git worktree
list reports the realpath while Orca still holds the /home request path,
so creation failed with "Worktree created but not found in listing".
After local worktree add, fall back to realpath when string comparison
misses. Keep WSL listings on string comparison only (host realpath is not
authoritative there).
Closes#10170
* test(worktree): harden symlink reconciliation authority
* fix(worktree): reconcile creation by Git branch identity
* test(worktree): reproduce symlink-root listing with real Git
* test(worktree): cover cross-platform reconciliation
* fix(worktree): keep reconciliation main-only
---------
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
An SSH connect whose relay deploy fails permanently (e.g. a dev build
missing the platform relay package) drove an unbounded reconnect loop.
doConnect creates the relay session ('idle'), then connectionManager.
connect() drives the raw transport to 'connected' before the relay
establishes. That premature 'connected' was forwarded to the renderer,
which treats it as "host fully up": applySshConnectionStateChange bumps
tab.generation for stranded remote panes -> remounts TerminalPane -> the
pane connect gate re-invokes window.api.ssh.connect -> restarts the whole
cycle forever (and spams connected-gated reads that fail with "Remote
connection dropped").
Hold the premature 'connected' at 'deploying-relay' in onStateChange until
the relay session is 'ready'. doConnect still broadcasts the authoritative
'connected' directly after establish() succeeds. Gate on connectInFlight so
the hold is scoped to a live connect and never wedges a stray transport-blip
'connected' on a session left 'idle' by a relay version mismatch.
* fix(worktree): stop terminal removal fence error flashing on delete
Deleting a worktree kills its PTYs for the filesystem teardown, then runs
git worktree remove (~1s). During that window a doomed pane races a fresh
respawn that main correctly fences with TerminalRemovalInProgressError, but
the renderer surfaced that internal fence verbatim as a pane error banner
until the worktree unmounted.
- startFreshSpawn: skip the respawn when the pane's own worktree is being
deleted (isDeleting) — no shell should spawn into a directory being removed
and the pane is about to unmount.
- reportError: swallow the removal fence at the single pane-error sink so it
never reaches the banner, covering the parent-removal-fences-child case the
own-worktree skip cannot see.
- Share the fence messages between main (thrown) and renderer (recognized) via
worktree-removal-fence-error.ts so the thrown text and predicate can't drift.
* test(worktree): assert onError callback captured before invoking
Optional invocation let the fence-suppression test false-pass if the
transport onError wiring broke; require the callback so a broken wire
fails loudly. Addresses CodeRabbit review on #10240.
* fix(app): bound the wake/quit paths implicated in the phone-session-ended freeze (#9447)
- relay-transport: waitForClose now times out (5s) so a half-open post-sleep
socket can't wedge runtimeRpc.stop()
- will-quit: race teardown against a 20s deadline so app.quit() always runs
(Force Quit was the only escape when any teardown member never settled)
- terminal-fit-restore: local restoreTerminalFit invoke gets the same 15s
bound as the remote path so the held-fit modal buttons can't pin disabled
* fix(app): close wake recovery timeout gaps
* fix(relay): drop late frames after forced teardown
* fix(relay): fence detached socket callbacks
* fix(app): close timeout resource gaps
* fix(relay): detach retired mobile transports
* fix(types): exclude absent stat overloads
* fix(runtime): expire wedged terminal restore dedupe
* fix(runtime): keep restore retries on one reclaim
* chore(skills): refresh bundled skill manifests
* fix(window): fence quit acknowledgements by request
* fix(relay): bound revoked device socket cleanup
* fix(status-bar): seed Resource Manager closed badge from daemon inventory
The closed chip counted tab/layout PTY wake hints (inflating terminal
count) and never fetched memory until the popover opened (showing "—").
Cache listSessions for the badge, seed memory on session ready, and drop
the wake-hint closed selector.
* fix(status-bar): update session inventory ref in an effect
CodeRabbit/React Doctor flagged mutating sessionInventoryRef during
render; keep the write in useEffect after commit.
* fix(status-bar): follow daemon session lifecycle events
* perf(status-bar): skip inventory scans for known PTYs
* perf(status-bar): bound resource inventory refreshes
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(quick-open): guide rg install after fallback budget errors
* fix(quick-open): show local host wording for local install guidance
The install-rg guidance component hardcoded 'on the remote', so the new
local fallback path told local users to install ripgrep 'on the remote'
— wrong for the exact case #9627 targets. Parse the location out of the
message and render the matching wording; add the local locale string and
a render test that guards against the 'on the remote' regression. Also
harden the reason capture against a stray ')' in the error text.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Replace the api.onorca.dev fallback pattern with same-host retries on
www.onorca.dev, the endpoint that owns crash delivery. This ensures
consistent behavior and clearer error reporting when failures occur.
- orca-runtime: 'stop cannot be proven' now requires the fresh PTY inventory
to still show the pty live (empty inventory proves exit post-#10106).
- git-common-watch: swap worktrees dir->file synchronously so no poll tick
observes the transient ENOENT window and fabricates a delete.
(cherry picked from commit e11736d7e170982eeebe09256eb549446a4f4f7b)
Co-authored-by: Orca <help@stably.ai>
* feat(dashboard-popout): size the terminal dialog's PTY to the dialog grid
The popout agent-terminal dialog rendered the pane's serialized frame at its
original cols/rows and CSS-scaled it down to fit. The dialog now claims the
PTY grid for its own box through the remote-desktop viewer registry: the PTY
reflows to the dialog's dimensions (crisp, unscaled), the main-window pane
parks at the held grid like any remote viewer hold, and closing the dialog
releases the claim so the pane reclaims its geometry. A phone-driven PTY
keeps the floor; the dialog then falls back to the scale-to-fit rendering.
Any grid change under a live preview stream (fit landing, host reclaim,
phone takeover) now pushes a resync so the dialog repaints from a snapshot
at the new grid — this also fixes garbled dialogs when the pane resized
while a dialog was open.
* fix(dashboard-popout): harden terminal grid claims
* perf(dashboard-popout): bound terminal preview resize work
* perf(main): gate idle git-common polling
Co-authored-by: Orca <help@stably.ai>
* fix(main): enumerate git-common worktrees every tick to catch coarse-FS add/remove
The worktrees-dir readdir was gated on the dir's mtime:ctime:ino:size signature, so on
a coarse-mtime/FAT filesystem a same-granule add+remove (all four fields collide) went
undetected until the ~30s index backstop. A single readdir of a small dir is negligible
next to the per-entry structural stats that already run every tick, so always enumerate —
the listing is the authoritative add/remove signal. The expensive per-entry index read
stays gated on each entry's own dir signature; onFullScan now reflects the ungated
index-metadata backstop fan-out (the real periodic cost) rather than the readdir.
Co-authored-by: Orca <help@stably.ai>
* fix(main): don't fabricate worktree deletions on a transient git-common readdir failure
Follow-up to always-enumerating the worktrees dir: the readdir catch-all treated ANY
error as an authoritative empty listing, so a transient failure (EIO/ESTALE/EMFILE,
network/SSH hiccup) emitted a false delete for every linked worktree (and a false create
next tick) — and enumerating every tick widened that exposure. Only ENOENT (dir truly
absent) now yields an empty listing; other errors retain the known entries so per-entry
stats still run and a real removal surfaces as that entry's own stat miss. Adds a
regression test (readdir → ENOTDIR) asserting no false delete.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(main): park worktree pollers while hidden
The per-repo worktree metadata pollers ran 24/7 with no window-visibility gate
(~50 fs.stat/sec at 20 repos on macOS while the window is hidden). Park the poll
timers while the window is hidden and resume losslessly (immediate fresh-vs-retained
snapshot diff) on reveal, via an injected WorktreePollerWindowVisibility mirroring
the ssh-port-scanner pattern.
Only the poll timers are gated; the darwin native worktrees/ fsevents watch and its
teardown/re-arm lifecycle stay always-on (push-based, ~0 idle cost; #8732 race).
A window is parked only once it has actually been shown and is now hidden: a live
never-shown window (ORCA_E2E_HEADLESS keeps one) and null/destroyed windows stay
always-visible, so a windowless/headless host never permanently parks the poller.
Co-authored-by: Orca <help@stably.ai>
* fix(main): keep start-to-start poller cadence after the interval→timeout change
Parking the pollers moved them from setInterval to a one-shot setTimeout chain, but
scheduling the next tick a full pollIntervalMs AFTER each scan completed turned the
cadence into gap-after-completion — every visible refresh landed ~one scan-duration
late per tick. Measure from tick start and schedule max(0, interval - elapsed) so the
cadence is start-to-start like the original interval, while keeping the one-shot chain
that park/resume needs.
Co-authored-by: Orca <help@stably.ai>
* fix(main): clamp poller reschedule delay to [0, pollIntervalMs]
Date.now() is not monotonic: a backward wall-clock jump (NTP correction) during a scan
makes (now - startedAt) negative, so the start-to-start delay pollIntervalMs - elapsed
would exceed one interval by the adjustment — suppressing visible metadata refreshes for
minutes/hours, unlike the former setInterval. Cap the computed delay at pollIntervalMs
(upper) as well as 0 (lower) in all three pollers.
Co-authored-by: Orca <help@stably.ai>
* refactor(main): drop redundant notifyTimer guards to stay under max-lines
The visibility-parking change pushed worktree-base-directory-watcher.ts to 303 code
lines (limit 300). clearTimeout tolerates null/undefined, so the two truthy guards
around it are redundant — remove them (coalescing null→undefined for the type). No
behavior change; back under the limit without an eslint-disable.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Allow the existing "Open in" entries to launch a configured VS Code
launcher against an SSH-backed worktree via Remote-SSH:
code --remote ssh-remote+<authority> <remote-path>
- Split the blanket SSH/runtime block into a capability model: file
managers and non-VS Code launchers stay local-only (disabled with
"Local only" metadata); a recognized VS Code command is enabled and
forwarded with connectionId over a typed object IPC.
- Main process stays authoritative: rejects active/owned runtimes,
resolves the SshTarget from the persisted Store, derives the authority
(config alias, or username@host on port 22, or ssh-alias-required on a
non-default port), validates POSIX/Windows absolute remote paths without
local stat/normalize, and rejects non-VS Code and compound commands
before spawn.
- Authority and remote path are passed as separate argv; getSpawnArgsForWindows
remains the cmd/bat shim boundary and fails closed on metacharacters.
- Same capability rules across the worktree menu, Explorer overflow, and
the source-control entry context menu.
Refs STA-2386
Closes#9999
* feat(mobile-pairing): surface unpaired-device auth failures instead of silent 4001 loops
Desktop: when a phone repeatedly fails direct-transport E2EE auth with a
token missing from the device registry (pre-v1.4.106 pairing-path bug left
desktops that regenerated their registry rejecting paired phones forever),
throttle to one notification per session and show an actionable toast
pointing at Settings -> Mobile to re-pair.
Mobile: map a bare 4001 close onto the existing auth retry budget (the
encrypted e2ee_error is undecryptable when the desktop keypair changed, so
the close code is the only surviving signal) instead of looping the generic
reconnect forever, and make the auth-failed verdict say 'Pairing invalid -
re-pair with your desktop' instead of a bare 'Auth failed'.
* fix(mobile-pairing): handle stale keys and startup notification races
* fix(mobile-pairing): isolate auth notification failures
* fix(mobile-pairing): keep recovery alert actionable