* fix(runtime): stop broadcasting terminalSideEffects to clients without consumers
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): keep mobile subscribers counted for side-effect availability
Excluding phones from the consumer-availability count added a new flip edge
(last desktop client leaving a phone-attached host), and the flip's tracker
rebuild cancels armed stale-working-title timers — stranding a 'working'
spinner on the phone. Availability counts all subscribers again; the
broadcast fix stays in the per-listener fan-out skip, now applied inside the
delivery callback so live-Set unsubscribe semantics and allocation-free
iteration are preserved.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): separate mobile title tracking from side-effect scans
---------
Co-authored-by: Orca <help@stably.ai>
* 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>
* fix(windows): make the GPU fallback actually remove the GPU child, and stop WSL latching absent
Three Windows crash/regression fixes from shipped 1.4.156/1.4.158/1.4.159 crash reports.
GPU fallback (cluster D, 14 reports, exit 0x80000003 STATUS_BREAKPOINT):
the software-rendering fallback called disableHardwareAcceleration() plus
--disable-gpu, neither of which removes the GPU child process — Chromium still
spawns it to host Viz and merely drops the backend to software GL. Measured on
Windows 11 / Electron 43.1.0: gpuProcessCount stays 1. So a GPU process being
killed by a bad driver or an injected DLL kept dying after the fallback engaged,
on every launch, for the life of that build (the marker is sticky per version).
The crash tails show exactly this: gpu_fallback_applied followed by another GPU
crash 1.3s later. --in-process-gpu is the only switch that drops the child count
to 0; --disable-software-rasterizer is deliberately excluded because it also
kills SwiftShader, which would drop every terminal to the DOM renderer.
WSL distro list: a successful-but-empty `wsl --list --quiet` was cached for the
process lifetime. `wsl --install` reports zero distros while one is still
provisioning, so an early probe latched "no WSL" until restart — WSL appeared
during setup and then vanished from the terminal picker. Empty results now
re-probe on an exponential window (15s doubling to a 5min cap) while staying
readable, so a missing distro is still visible to isKnownMissingDistro.
WSL availability: isWslAvailable() latched false on any failure via a bare catch,
so one slow wsl.exe activation disabled WSL for the whole session. Failures are
now classified — a numeric exit status or ENOENT is answer-shaped and holds for
10min, anything else (timeout, spawn failure) retries after 45s — and both back
off per consecutive failure, mirroring isPwshAvailable.
Windows-only: every changed path is behind an existing process.platform check,
so macOS and Linux behaviour is unchanged.
* fix(windows): drop a stale WSL availability failure once a distro list succeeds
The distro-list and availability caches expire independently, and
getWslRepairReason checks availability first. So a definitive availability
failure (numeric exit status or ENOENT) held for 10-30min would keep reporting
`wsl-unavailable` even after `wsl --list --quiet` successfully returned a
distro — i.e. over a WSL that demonstrably just answered. That is the same
latch class this branch fixes, surviving in the gap between the two caches.
A non-empty distro list proves wsl.exe ran, so drop the negative availability
cache and let the next call re-probe. Scoped to non-empty lists only: those are
cached for the process lifetime, so this cannot re-spawn the blocking 5s probe
more than once. An empty list keeps its failure cache, since it re-probes on a
15s-to-5min schedule and would otherwise pay the blocking probe far too often.
* fix(windows): harden GPU safe mode and WSL recovery
* fix(wsl): make capability refresh cleanup explicit
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(ssh): key the PTY model-migration fence by app pty id
Co-authored-by: Orca <help@stably.ai>
* test(ssh): pin the post-recovery checkpoint rekey
The finishSourceRecovery app-id rekey had no coverage: reverting it left every
suite green while reconnects silently resumed from the stale migration-era
checkpoint.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(relay): stop re-encoding frames to size PTY chunks
maxLegacyPtyDataChars now sizes the full candidate once (the common case)
and falls back to a binary search over an exact cheap byte formula instead
of fully encoding the frame at every probe. Publish paths thread their
already-computed frame estimate into enqueueFrame so each PTY publish
encodes the message once for admission instead of twice.
Co-authored-by: Orca <help@stably.ai>
* fix(relay): preserve dispatcher frame guards
---------
Co-authored-by: Orca <help@stably.ai>
A retry that carries a memoized sourceChunk must republish that exact span
and slice the remainder at the memo boundary. Recomputing chunkChars on
retry dropped coalesced tails when capacity grew, duplicated already-
published chars when it shrank, and discarded coalesced transformed raw
advances behind a source-only memo. The remainder entry now also keeps
transformed/rawLength/seq bookkeeping so a transformed tail stays a
well-formed emission.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): recover pairing save when the Android keystore alias is unusable
Orca Mobile could reach a state where pairing succeeded but the host could
never be saved, with every attempt failing identically:
Could not encrypt the value for key 'orca.host-token.host-...'
under keychain 'key_v1'. Caused by: unknown
expo-secure-store derives ONE Android keystore alias from the keychain
service (`<service>:unauthenticated`) and shares it across every host token,
so a single unusable alias rejects all writes. Its built-in self-heal only
covers KeyPermanentlyInvalidatedException, and a null-message
GeneralSecurityException takes the unrecoverable branch instead — leaving
onboarding permanently blocked, which a reinstall does not clear.
Route host-token persistence through a keychain generation that rotates to a
fresh service (and therefore a fresh alias) only after a write has already
failed. Generation 0 keeps expo's default service so tokens written by
earlier builds stay readable, reads walk back through retired services, and
deletes clear every generation so a rotation cannot strand a live credential.
Refs #6600
* fix(mobile): record a keychain rotation before storing the token under it
Greptile flagged that a token could be stored under a generation the
generation record never captured. `commitGeneration` swallowed the
AsyncStorage failure and cached the new generation in memory, so the write
succeeded for the rest of the session — but the next launch re-read the old
record, and because reads only walk back from the recorded generation they
never probed the newer service. The host silently vanished and the user had
to re-pair, which is the same class of loss this change set out to fix.
Record the rotation first and let a storage failure propagate, so a token is
never written under a generation reads won't reach. Advancing the record
before the write is safe because reads walk back through every older service;
the worst case is one spent generation and one extra probe per miss.
* fix(mobile): harden pairing keychain recovery
* fix(mobile): harden pairing keychain recovery state
* fix(mobile): fail closed on unreadable pairing credentials
* fix(mobile): keep terminal input composable while the connection is cut
Fixes#6713. While the socket was down every input control on the mobile
session screen was hard-disabled by the single canSend gate — the keyboard
would not even open, and everything typed during the outage was silently
discarded.
Split the gate: canCompose (local composing, survives an outage) vs canSend
(needs the live socket). The buffered command box stays editable offline and
holds the text; the send button, accessory keys, and live-input capture stay
connection-gated; the live/buffered mode toggle stays tappable so live-mode
users can reach the compose box. The return-key submit path holds composed
text instead of firing a doomed RPC.
Also reset the live-input mirror when the connection drops: bytes sent into a
stalled link are lost but were recorded as delivered, so the first
post-reconnect send replayed stale fragments or emitted phantom erases
(observed as `YZZYecho CLEANLINE` corrupting the next command on device).
* fix(mobile): stop stalled terminal input replaying into the PTY after reconnect
Device verification of the first commit surfaced the real replay vector for
the second defect: sendRequest parks in waitForConnected while disconnected,
so live-mirror deltas queued behind a dying send drain into the connect wait
and fire on the next socket — bytes typed during an outage executed tens of
seconds later (observed on device as the prompt reading `nOPQ` after
reconnect with no post-recovery typing).
Add SendRequestOptions.failWhenDisconnected — reject now instead of parking —
and opt in every keystroke-grade terminal send: live mirror, accessory keys,
buffered command send, and gesture arrows. Deliberate command sends
(initialPrompt on terminal create) keep the connect wait.
terminal.send param construction moves to terminal-send-request.ts and the
accessory raw-send tail to terminal-live-accessory-raw-send.ts.
Re-verified on simulator through a blackhole cut-proxy: text typed during the
stall no longer replays, and the first post-recovery command executes verbatim.
* test(mobile): assert route-slice anchors are unique so pins cannot slice the wrong region
* docs(mobile): trim replay-fix comments to one-line rationale
detectLanguage lowercases the extension before the table lookup, so the
'.R' entry can never be reached — '.r' already covers every casing. It is
also the only key in the table with an uppercase character, and leaving it
suggests uppercase extensions need their own rows.
* refactor(settings): unify Linear setup into guided flows across Task Sou
Consolidate Linear API connection, agent skill installation, and Tasks visibility into guided step-by-step workflows. Moves Linear setup from a hidden integrations link into expandable cards within Task Sources (first-time path) and a prominent checklist in the Linear settings pane. Adds reusable TaskSourceProviderCard and step components so GitHub, GitLab, and Jira follow the same pattern. Surfaces incomplete provider warnings and auto-expands the first unfinished provider to reduce friction.
* refactor(settings): unify Linear setup into guided flows across Task Sou
Extract shared Linear skill setup logic into `useLinearAgentSkillSetup` hook so
Task Sources and LinearAgentSkillPane both follow the same configuration path.
Embed the skill install panel inside LinearAgentSkillGuide instead of placing it
after, eliminating duplicate "Agent skill" section headers. Introduce task
provider setup state helpers: distinguish fresh untouched providers from
started-then-stalled ones (only warn on the latter), keep auto-expanded cards
open across rechecks via sticky tracking, and handle unavailable/failed
preflight status. Rename BrowserUseStepBadge to SetupStepBadge (used by
multiple setup types now). Route connected credential management to Integrations
cards instead of re-opening dialogs. Add settled flag to skill discovery so
focus-triggered rescans don't flash UI on known results.
* chore: credit task source guidance contribution
Co-authored-by: Chen <zwq19980411@gmail.com>
* fix: clear loading state regardless of generation's showLoading flag
When a silent refresh supersedes an in-flight focus rescan, the losing
rescan's finally block writes are dropped by the generation guard. Only
the winning generation clears loading, so it must clear regardless of
its own showLoading setting. Also move TasksPane auto-expanded tracking
from effect to render phase to prevent layout-effect re-renders from
seeing an unclaimed slot and collapsing the card.
* fix: stop mutating refs during render for React Doctor
Replace render-time ref writes in TasksPane sticky auto-expand and
active project skill runtime identity caching with setState-during-render
so static analysis can pass without changing behavior.
---------
Co-authored-by: Chen <zwq19980411@gmail.com>
* fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348)
The browser pane's renderer-path Find handler is a window-global
capture-phase keydown listener, but it armed on `isActive` (the active
tab within its own group) rather than on whether its split holds focus.
In a terminal+browser split, the browser was therefore `isActive` even
while the terminal held keyboard focus, so it swallowed Cmd/Ctrl+F and
opened find-in-page in the browser instead of find-in-terminal.
Thread a focused-split signal (`isFocused`) from BrowserPaneOverlayLayer
— derived from `activeGroupIdByWorktree` — down to the Find handler and
gate the listener on it. This mirrors how terminal leaves already gate
global shortcuts via `focusedGroupId` in TabGroupSplitLayout. Floating
browser panels omit the prop and fall back to `isActive`, preserving
their behavior. The IPC path (webview guest focused) is unchanged; it
only fires when the guest genuinely has focus.
Not platform-specific: the chord resolves through `keybindingMatchesAction`
(Mod -> metaKey on macOS, ctrlKey elsewhere), so the same path is fixed on
macOS, Linux, and Windows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(browser): preserve Find before split focus settles
* fix(browser): handle stale focused split IDs
* fix(browser): route guest Find to source page
* test(browser): wait for split address bar
* test(browser): focus split before Find routing
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(mobile): render the terminal caret for main-buffer TUIs
The mobile WebView never flipped xterm's isCursorInitialized, which both
renderers check before they ever read cursorStyle/cursorInactiveStyle. The
native TextInput owns keyboard focus and xterm's textarea is inert, so the
focus and keydown paths never fire, leaving DECSET 1049 as the only way to
flip it. Alt-screen TUIs got a caret as a side effect; Claude Code, which
redraws its composer in the main buffer, never did.
Set showCursorImmediately so the caret does not depend on focus, and switch
cursorInactiveStyle to block: mobile is permanently unfocused, so that option
is what renders, and a bar is dpr device px wide and disappears under the fit
scale() the WebView applies.
Refs #8313, #7093
* test(mobile): prove main-buffer caret rendering
* test(mobile): calibrate terminal listener cleanup
* test(mobile): keep caret oracle teardown assertion-free
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(editor): map .cts/.mts to the typescript language id
The comment above EXT_TO_LANGUAGE already documents that Monaco maps
.tsx/.cts/.mts onto the typescript language id, but only .tsx was in the
table, so .cts/.mts files opened as plaintext.
* fix(mobile): map cts and mts to typescript
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(checks): rank successful checks above skipped and neutral
Checks were ordered with `skipped` (4) and `neutral` (3) ahead of
`success` (5), so a PR with a long tail of skipped jobs pushed every
passing check below the fold — you scroll past a wall of "Skipped" to
find out whether anything actually ran.
Rank the no-signal conclusions last (`success` 3, `neutral` 4, `skipped`
5) and pull the order out of its three duplicated copies
(checks-panel-content, PullRequestPage, GitHubItemDialog) into
`src/shared/pr-check-severity-order.ts`. Unknown conclusions now sink to
the bottom instead of silently ranking as `neutral`.
* fix(checks): look up check ranks through a Map, not an object literal
An object-literal rank table resolves `constructor`, `toString`, and
`__proto__` off Object.prototype, so those keys returned a function
instead of falling through to UNKNOWN_CHECK_RANK — the comparator then
subtracted functions, went NaN, and left the list in arbitrary order.
Conclusions come from provider payloads, so keep the lookup on a Map and
cover prototype property names in the test.
* test(checks): cover provider-neutral ordering states
* fix(checks): preserve actionable provider states
* fix(checks): preserve unresolved provider rollups
* fix(checks): keep unknown GitLab rollups neutral
* fix: preserve neutral review check summaries
* fix: complete provider-neutral check ordering remediation
* fix: use provider-neutral mobile review status input
* fix: hydrate GitLab mobile review status
* fix: type mobile GitLab review hydration
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(skills): stop a project's own skill copy driving global freshness
A pristine global install plus a drifted copy inside a work directory showed
Settings -> Computer Use as amber "Needs attention", with the copy labelled
"may be modified ... Remove it if you want Orca to update this skill", while the
same page said "Computer Use is ready" and the update command correctly printed
"All global skills are up to date". No user action could clear it.
Orca's updater only ever passes --global, so a copy a project owns has no remedy
by design. Two defects made it drive the global verdict anyway:
- locationChip tested byte status before topology, so an unrecognized repo-scope
placement returned early and never reached its 'in-a-repo' case. Because
SKIPPED_REASON_PRIORITY ranks unrecognized above in-a-repo, the summary
sentence was wrong too.
- isSkillCopyNeedingAttention excepted plugin-cache but not repo-scope.
Stated by scope rather than by byte status: an outdated or unreadable project
copy is as far outside the global updater's reach as an unrecognized one, so
pinning only the reported status would leave the same bug reachable through
another. Chip precedence is now explicit -- a read failure outranks ownership so
that rule cannot hide a real fault, and ownership outranks byte status.
Ownership suppresses the group, never a location's visibility: a project copy is
still listed whenever another placement earns the row.
The badge predicate deliberately omits the shared helper's outdated carve-out, so
a non-eligible outdated copy stays amber. Collapsing the two into one predicate
would flip that to green while the dialog still shows its reinstall row, so the
distinction is preserved and pinned by a regression guard.
Eligibility needed no change: it already filters to convergent placements.
* fix(skills): keep a project copy from explaining a global skill's skip
Review follow-up. The chip and the group no longer treat a project-owned copy as
global drift, but three surfaces still read it as one:
- skippedReason derived its one sentence from the highest-priority chip among a
group's locations, with 'in-a-repo' ranked above 'duplicate', 'external-link',
'broken-link' and above the no-chip case that hands over the reinstall command.
A repo-scope copy can no longer earn a group, so whenever it won it explained a
skip it had no part in — and swallowed the one runnable remedy. SkillLocationRow
now carries whether the update judged it, and only judged rows explain. That also
covers the scan-limit sentinel, which is repo-scope and chips 'inaccessible'.
- hasSkillCopyNeedingAttention counted project copies as the presence that makes a
plugin-cache read failure a skill's problem, while the status function skipped
them — the disagreement the two exist to prevent.
- The nudge mixed project copies into its dismissal fingerprint, so re-checking out
a repo re-raised a nudge the user had already dismissed.
plugin-cache is untouched: it stays on the judged side everywhere, because updating
the plugin is a remedy a project copy does not have.
* fix(skills): stop OS sidecars marking an untouched skill as modified
Package identity compared a live user directory against a tree read from a
clean checkout, so anything the OS deposited counted as drift. One Finder
visit writes .DS_Store, which sorts before SKILL.md and misaligns the
index-aligned snapshot comparison — the copy became 'unrecognized', was
reported as "may be modified... Remove it", and left out of the update.
Running the update could not clear it either: the updater compares its lock
to the source and never reads disk, so it correctly reports "up to date"
and writes nothing.
Ignore OS-authored names on both sides of the comparison. The generator
half is not hypothetical: a stray sidecar in a working tree made the
committed artifacts read as stale, failing lint for that developer.
Scoped to OS-authored names only. Tolerating unexpected files in general
would let an injected payload ride along beside a clean SKILL.md; these are
safe because an official SKILL.md never references them, so no agent can be
routed into one. Mode bits are deliberately untouched — that would weaken
identity for real scripts.
* fix(skills): keep guarding a directory or link wearing an OS metadata name
The name-only skip dropped any entry matching an OS metadata name, so a
directory named .DS_Store or ._scripts took its whole subtree out of
identity and a symlink wearing one stopped tripping the link guard — a
skill hiding either read as pristine. The OS writes these as plain files
only, so the entry type decides, still ahead of the case-fold map.
Also compares both walkers over the same fixture: an asymmetric skip is
worse than none, since one side would bake in content the other can
never observe.
* chore: ignore the OS metadata names skill identity already skips
Both skill-identity walkers ignore these names, but .gitignore covered only
.DS_Store and Thumbs.db — so a stray ._SKILL.md showed as untracked and
`git add -A` could commit it. That is the one way the two walkers can
disagree: the disk walker skips such a file while the git-tree producer
(collectGitPackageFiles, used by the unreferenced --rebuild-from-tags path)
does not, so a committed sidecar would make released history and observation
describe different content.
Ignoring them keeps that asymmetry unreachable rather than adding a second
skip to the released-history path, which is load-bearing and provably never
sees one today: no committed sidecar exists on any ref.
Nothing tracked matches the new patterns.
* chore: correct the skill-identity ignore comment
The previous wording claimed these names cannot be committed, which
overstates what .gitignore provides: `git add -f` and `git apply --index`
both bypass it, so a cherry-pick, rebase or fork branch already carrying a
sidecar is unaffected. That clause was load-bearing — it was the stated
reason for leaving the released-history producer unhardened — so it should
not read as a structural guarantee.
Also fixes the producer count (three, not two: two disk walkers plus the
git-tree producer, which does not skip) and says plain file, since the skip
is isFile()-gated so a directory or link wearing the name is still walked.
types.ts sat 9 effective lines under the 300 max-lines cap, which forced
#10065 to type onWriteUnavailable via a cast at the pty.ts call site.
Split into pty-provider-contract.ts, filesystem-provider-contract.ts, and
git-provider-contract.ts; types.ts stays a barrel owning IProviderRegistry
and re-exporting every prior symbol, so no caller import changes.
Largest new file is 105 effective lines.
With headroom back, onWriteUnavailable is now declared on IPtyProvider
(optional — only respawnable endpoints like the daemon adapter signal it)
and the pty.ts cast is gone.
ws.onopen zeroed reconnectAttempt before the handshake, so any endpoint
that accepted the socket but never authenticated pinned the counter at
0-1: no escalation gate could fire, backoff never grew, and every screen
showed "Connecting…" forever (issue #10119). Reset the counter on
e2ee_authenticated instead, and make classifyConnection apply the
warning/unreachable gates during connecting/handshaking so an escalated
verdict latches through redials.
* fix(mobile): stop re-creating a terminal when the session tab list empties
The session route treated "zero session tabs" as "this workspace has never
had anything" and auto-created a terminal. Closing the last tab prunes
sessionTabs and nulls activeHandle locally, which is exactly that state, so
the close was immediately followed by a brand-new terminal — and the guard
re-arms on every route mount, so it recurs across visits (#9717, #7345).
Gate the auto-create on whether this route has ever published a non-empty tab
list for the workspace. A cold hydrate still gets its first terminal; an
emptied list gets the empty state and its create button.
Extracted to a hook because the route file sits at its max-lines cap; the
call site is 4 counted lines smaller than the effect it replaces.
* fix(mobile): keep emptied workspaces empty across visits
* fix(mobile): reach the auto-create callbacks without a render-time ref write
The hook kept `consumeCreationRoute`/`createTerminal` out of the effect deps by
writing latest-refs during render. React can replay or discard a render, so the
write can leak from UI that never commits — React Doctor flags it as a blocking
"Ref mutated during render" error, which failed PR Checks' static analysis.
useEffectEvent (React 19.2, already used in SourceControl.tsx) gives the same
stable-callback-outside-deps behaviour with no render-time mutation. Retire the
deprecated `MutableRefObject` for `RefObject` in the same pass.
Retargets the source pin at the new wiring; test counts unchanged.
* docs(mobile): document the two per-route reset contracts
Both exported helpers exist for a non-obvious reason — they must be re-created or
re-derived per worktree, or a reused route inherits the previous workspace's
hydration state and the resurrection guard silently disarms.
* fix(mobile): preserve terminal creation through reconnect
* fix(pty): do not create unused Pi/OMP home dirs on bare shells
Bare terminals used to materialize ~/.pi/agent and ~/.omp/agent (and install
managed extensions) for possible later shell-launched agents. Users who never
use those agents still saw the directories recreated after deletion.
Only create the default agent home when launching that agent explicitly, or
when the home already exists. Bare-shell OMP status still uses the userData
fallback so typed `omp` keeps the shell wrapper extension.
Closes#10196
* fix(relay): OMP bare-shell status fallback without ~/.omp
CodeRabbit: relay materializePi returned null on bare shells with a missing
OMP home, so SSH PTYs never set ORCA_OMP_STATUS_EXTENSION. Local already
wrote a userData-managed status extension in that case.
Write the status file under ~/.orca-relay/omp-managed-status-extension and
return MaterializePiResult so relay.ts can export ORCA_OMP_STATUS_EXTENSION
without ORCA_OMP_SOURCE_AGENT_DIR or creating ~/.omp. Also fix the local
withOrcaManagedExtensionMarker typo on the bare-shell path.
* fix(pty): only materialize Pi home for Pi launches
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* feat(cli): add `orca account add` / `account list` for headless hosts
The desktop "Add account" UI is disabled when the renderer drives a remote
runtime (isRemoteAccountScope === kind:'environment'), so a headless server
reached from a remote desktop/web client has no way to register managed
Claude accounts. Add a host-local CLI path that reuses the existing capture
logic:
- ClaudeAccountService.addAccountFromConfigDir(): register a managed account by
capturing credentials from an already-authenticated CLAUDE_CONFIG_DIR instead
of spawning the interactive browser login (extracted persist/rollback helpers
shared with the existing add flow)
- RPC accounts.addClaudeFromConfigDir, bridged via OrcaRuntime; rejected for
mobile device tokens (host-local only)
- `orca account add` runs `claude login` in the user's own terminal into a temp
CLAUDE_CONFIG_DIR, then registers it via the local runtime; `orca account list`
lists managed accounts
Switching (select) already works from a remote client; only adding was blocked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cli): support Codex in `orca account add` / `account list`
Mirror the Claude headless-account CLI for Codex:
- CodexAccountService.addAccountFromHome(): register a managed Codex account by
importing auth.json from an already-authenticated CODEX_HOME, reusing a shared
persist helper extracted from doAddAccount (no interactive login spawned here)
- RPC accounts.addCodexFromHome + OrcaRuntime.addCodexAccountFromHome bridge,
rejected for mobile device tokens (host-local only)
- `orca account add --agent claude|codex` (default claude); `orca account list`
now renders both Claude and Codex managed-account blocks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: cover headless account-add capture paths (Claude + Codex)
- ClaudeAccountService.addAccountFromConfigDir: registers a managed account by
capturing an authenticated CLAUDE_CONFIG_DIR; rejects and rolls back when the
dir has no .credentials.json
- CodexAccountService.addAccountFromHome: imports auth.json from an
authenticated CODEX_HOME into a managed account; rejects when auth.json is
missing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review on headless account-add flows
- CLI login spawn uses a shell on Windows so `.cmd` agent shims resolve without
ENOENT (args are fixed literals, no injection risk)
- Claude capture skips the `.credentials.json` precheck on macOS, where creds
live in the Keychain and captureAuthFromConfigDir reads them
- Claude add rollback is best-effort: a failed rematerialization no longer skips
managed-auth cleanup or masks the original add error
- Codex persist restores the prior account/selection if a post-write sync or
rate-limit refresh fails, so a failure can't leave a dangling managed account
- Codex sync passes the account's selection target (correct runtime for WSL)
- Add JSDoc to the new public service methods and CLI functions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): harden headless account capture
* fix(cli): correct account command flag surface and interrupt cleanup
- `account` commands no longer accept or advertise the browser `--page`
flag; `supportsBrowserPageFlag` allow-listed them by omission, so
`orca account list --page x` was silently accepted and `--help`
rendered a browser-only option
- account specs declare GLOBAL_FLAGS, so `--help`/`--json` render in the
Options block like every other command
- `--agent` on `account add` documents the account provider instead of
the terminal TUI-agent meaning inherited from the shared flag table
- a SIGINT/SIGTERM during the interactive login now removes the temp
login dir (and restores the macOS Keychain item) before exiting 130;
Node terminates without unwinding `finally`, which stranded live OAuth
credentials on disk
* perf(cli): stop `account list` forcing a provider usage refresh
`accounts.list` awaited refreshAccountsForMobile(), which runs
fetchAll({ force: true }) — bypassing both the poll throttle and the
per-provider Retry-After gate — then O(N) serial per-account round
trips. `orca account list` renders only emails and the active ids, so
all of that work was discarded. The RPC now takes `refreshUsage`
(default true, so mobile and web keep the forced lane) and the CLI opts
out. Older hosts declare `params: null` and ignore the field, so a newer
CLI degrades to the previous behavior rather than failing.
Also documents on `account list` that `--environment` does not retarget
it, matching the host-local behavior of shouldIgnoreRemoteSelection.
* fix(cli): survive repeated and hangup signals during account add
withInterruptCleanup latched cleanup behind a boolean, so a second signal
got an already-resolved promise and its process.exit fired while the first
cleanup was still inside a Keychain call (3s each) — the temp dir's OAuth
credentials and the swapped macOS Keychain item both survived. Memoize the
cleanup promise so every signal awaits the same run, and register with
`on` instead of `once` so a second Ctrl-C cannot fall through to Node's
terminate-immediately default mid-cleanup.
Handle SIGHUP too. This flow exists for headless/SSH hosts, where the most
likely interrupt is the connection dropping, which hangs up the login's
terminal and previously ran no cleanup at all.
Warn when the interrupt lands after sign-in completed: the runtime finishes
the add independently of this process, so exiting 130 silently would tell
the user it was cancelled when the account may exist.
Reject a valueless `--agent`; the parser turns it into boolean true, which
silently ran a full OAuth login for Claude when the user asked for another
provider.
Also lock two behaviors the refactor changed but left uncovered: a WSL Codex
add must sync the WSL runtime lane rather than the default host lane, and
rename the account-spec help test to describe the Options block it actually
asserts rather than the usage string it never reads.
* fix(build): bundle the main modules the account CLI imports
electron-vite cleans out/main and emits only its declared entries, and
`build:desktop` runs it after `build:cli`, so the tsc-emitted copies of
`claude-accounts/keychain`, `codex-cli/command` and `win32-utils` were
deleted before packaging. Both `orca account add` and `orca account list`
then died at require time with "Cannot find module
'../../main/claude-accounts/keychain'" — reproduced against a real
`--serve` host. `agent-hooks/managed-agent-hook-controls` already carried
an entry for exactly this reason; these three were missing.
Adds a parity test so any future CLI import of a `src/main` module fails
in CI rather than at a user's shell after packaging.
* test: cover the desktop add-path behavior this PR changes
Both changes ride in the persist/rollback helpers the existing GUI add
flow shares with the new headless path, and neither had coverage:
- Claude: rollbackAddAccount now guards forceMaterializeCurrentSelection-
ForRollback, so a rejecting rematerialization no longer replaces the
real add error nor skips safeRemoveManagedAuth. Asserts the original
error surfaces and the throwaway auth dir is gone.
- Codex: the desktop add now passes the account's selection target to
syncForCurrentSelection, matching reauthenticate and select. Asserts
the host target alongside the existing WSL assertion.
Both fail when the corresponding change is reverted.
* fix(cli): close the remaining account-add interrupt and preflight gaps
The round-1 interrupt fix detached the signal handlers before running the
finally-path cleanup, so the very window it was meant to protect — the two
serial 3s `security` calls plus rmSync on the success/error path — was
still covered only by Node's terminate-immediately default. Both review
lanes reproduced it independently. Await cleanup first, detach in a nested
finally, and stop a cleanup failure from replacing the error that actually
explains why the add failed.
Do not burn the interactive login when the runtime is unreachable. The
RuntimeClient is lazily constructed and the first call was the registration
RPC itself, so "Requires the Orca runtime to be running" was discovered
only after the user completed a full OAuth round trip. Preflight with the
now-cheap `accounts.list { refreshUsage: false }`.
Reject `--environment` / `--pairing-code` on `account add`.
shouldIgnoreRemoteSelection pins account commands to the local runtime, so
`orca account add --environment homelab` silently registered the account on
the laptop instead of the headless host it names.
Survive a daemon that cannot spawn `claude`. `allowFailure` is honored in
onClose but not onError, and unlike the GUI flow nothing has run `claude` in
the daemon before this point — so a launchd/systemd daemon with a minimal
PATH hard-failed an add the user had already signed in for, even though
identity resolves fine from the config dir's oauthAccount.
Also align the `--agent` help description with the global flag column.
* fix(cli): reject runtime selectors on `account list` too
`orca account list --environment homelab` was accepted and silently
listed the LOCAL machine's accounts, because shouldIgnoreRemoteSelection
pins account commands to the local runtime. Documenting that in --help
does not reach someone who already typed the flag, and answering with the
wrong host's accounts is the specific wrong answer they would act on.
`account add` already errors; this makes the new command group internally
consistent. The other groups in shouldIgnoreRemoteSelection keep their
existing silent-ignore behavior — changing those is not this PR's job.
* test: harden account-add signal tests and cover cleanup failure
- Identify the handler under test by set difference instead of
`process.listeners(sig).at(-1)`. Vitest installs its own once-wrapped
SIGINT teardown, so the positional lookup could grab the wrong listener;
the helper also asserts exactly one new listener was added.
- Mock rmSync while keeping the real implementation by default, so the
temp-dir assertions elsewhere stay honest.
- Cover that a cleanup failure in the `finally` does not replace the error
explaining why the add failed. Fails when that guard is removed.
Completes the review loop's final round; the loop died on an API error
before it could commit this, and its `import()` type annotation would
have failed oxlint.
* fix(cli): harden interactive account add
* test(cli): make account cancellation coverage portable
* fix(cli): preserve merged skills runtime modules
---------
Co-authored-by: Dominik <marketing@gavaplast.sk>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path.
**Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS.
The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.**
`universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record<TuiAgent, …>` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list.
Fixed during review — two holes that each restored the full fan-out through a different door:
- `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`.
- `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list.
The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser.
Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine.
Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte.
Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for.
Co-authored-by: scastanoh21 <scastanoh21@gmail.com>
* fix(native-chat): mirror multi-line launch drafts into the chat composer
seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.
Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.
Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.
* fix(native-chat): send the mobile clear burst as its own write
Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.
The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.
Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.
* test(native-chat): invert the multi-line Linear launch-draft mirror expectation
The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.
Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.
* fix(native-chat): preserve launch draft send contents
* fix(native-chat): preserve confirmed send queue ordering
* fix(native-chat): preserve send pacing after renderer stalls
* test(native-chat): align activation with multiline draft mirroring
* fix(native-chat): clear launch drafts from any cursor
* fix(native-chat): retire mobile-consumed launch drafts
* test(mobile): stabilize QR capacity boundary fixture
* fix(relay): refuse silent fallback when pairing invite fails
When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options.
* fix issues
* fix(sidebar): stop background workspace creation from scrolling the sidebar
Creating a workspace in the background still spawns its terminals, and the
renderer treated "no presentation stated" as "point the user at this
terminal" -- revealing (scrolling to) the owning workspace.
Split adoption from surfacing with an explicit surfaceOwner flag: background
worktree creates and worker dispatch adopt their tabs silently, while
`orca terminal create` keeps its discoverability reveal.
* fix(sidebar): keep split-mode setup panes silent, tighten surfaceOwner
Review catch: with setupScriptLaunchMode split-vertical/horizontal the Setup
terminal goes through splitTerminal, whose reveal payload had no surfaceOwner,
so a background create still scrolled the sidebar in that configuration.
Also narrow surfaceOwner to `false` so "surface it" can only be expressed by
omitting the key, and fold the repeated conditional spreads into ownerSurfacing.
* fix(worktrees): stop terminals after external deletion
* fix(worktrees): request teardown per caller and revalidate uncached
Two defects let the original fix silently strand PTYs:
- teardown rode the scan's coalescing promise, so any caller that joined an
in-flight scan purged its renderer state without ever asking for a sweep;
it now runs per caller against its own known-id snapshot, deduped on the
request it actually produces so fan-out still shares one host sweep.
- the runtime's authoritative recheck was served from the 30s worktree-scan
cache, which can still list a directory git already dropped. The renderer
purges either way, so a stale miss leaked those processes permanently.
Co-authored-by: Orca <help@stably.ai>
* perf(worktrees): enumerate the host once per teardown sweep
An agent cleaning up N workspaces made killAllProcessesForWorktree issue one
full provider enumeration per missing worktree: O(N) relay round-trips carrying
O(N^2) rows. At 30 worktrees over an 80ms-RTT SSH link that is 30 scans and
~1.3s of stalled teardown; it scales linearly from there.
Share one point-in-time process list across the sweep — every worktree in it is
already known-missing, so a single snapshot answers all of them. A failed scan
is never shared: it falls back to a per-caller scan so one transient relay error
cannot suppress the sweep for the whole batch. Pinned requirePhysicalStop:false
since that path re-lists after shutdown and must not read a pre-shutdown snapshot.
Co-authored-by: Orca <help@stably.ai>
* test(worktrees): pin the disconnected-SSH no-teardown invariant
main's new directSshAuthority gate bails before any refresh when an SSH target
is not connected. That is exactly the #10562 safety rule — "host unreachable"
must never be read as "worktree deleted" — so pin it: a disconnected target
issues no teardown RPC and keeps its renderer state.
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): keep selector grammar intact when scoping by connection
resolveRepoSelectorForConnection matched the selector as a bare repo id, so an
explicit connection identity silently changed the grammar: `path:` and `name:`
selectors resolved to repo_not_found on that path alone, losing the whole sweep.
A connection identity should only *narrow* the candidate set.
Extract the selector matching both paths now share, and stop re-resolving an
already-resolved repo: teardown rescanned via `id:<repo.id>`, which throws
selector_ambiguous when an id is duplicated across hosts even though the
caller's own selector was unambiguous.
Reported as a P2 by Greptile (as redundant work); it is load-bearing.
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): keep the shared snapshot out of provider internals
The snapshot proxy passed itself as the Reflect.get receiver, so prototype
methods invoked through it ran with `this` bound to the proxy. A provider whose
own shutdown() re-read state via `this.listProcesses()` would then silently get
this sweep's cached snapshot instead of the live host — batching leaking past
the calls it was built for.
Bind non-listProcesses members to the target so only the sweep's own calls share
the snapshot. No shipped provider does this today; the point is that adding one
must not quietly change teardown semantics.
Raised by Greptile as an undocumented implicit constraint; closed structurally
rather than by comment.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Match Run on field layout so short provider details like stablyai/orca
sit on the far right of the committed Project field instead of next to the name.