Files
orca/src/main/ipc/ssh.ts
T
+3 36277801e4 Make remote hosts first class: concurrent multi-host workbench (#5071)
* Restore the outlined server card for host headers

Feedback: the bordered card with the server glyph made it clearer that
a host section is a separate machine, not just another group. Bring
that back while keeping the recent quieting: no status dot when
healthy (marks only for connecting/blocked/error/disconnected), no
'This computer' detail on the local host, and collapse/menu/count
behavior unchanged.

Co-authored-by: Orca <help@stably.ai>

* Anchor host badge to its label, indent rows under host cards

Sidebar polish from review:
- The count badge sat in dead space between the label and the
  hover-only chevron/menu; it now hugs the label like repo headers
- Rows under a host card get a left inset so projects and workspaces
  visibly belong to the machine above them
- A host whose only visible row is a collapsed repo group counted 0
  while the group badge said 9; host counts now fall back to header
  counts for groups contributing no visible items

Co-authored-by: Orca <help@stably.ai>

* Two-tier sticky headers: pinned host card above pinned group header

When scrolling inside a host section, the host card now stays pinned at
the top (z-30) while project/status group headers hand off beneath it
(z-20, offset by the pinned card height). The host is the outer
hierarchy level, so it is the most persistent context — previously the
first repo header replaced it, losing 'which machine am I on' exactly
when it mattered. The pinned card keeps its collapse/menu/warning
affordances. Handoff rules: the next host card pushes the previous one
out at the viewport top; a group pins only once it reaches the slot
beneath the host card, and a previous host's group can never pin under
the next host. Without host sections the logic degrades to the original
single-tier behavior.

Co-authored-by: Orca <help@stably.ai>

* Revert host-section row indent

The two-tier sticky host card now provides continuous 'inside this
machine' context at any scroll depth, making the static indent
redundant — and it cost 12px of sidebar width on every row while
making multi-host layouts misalign with single-host ones. Host cards
bracketing their sections plus the pinned header carry the ownership
signal on their own.

Co-authored-by: Orca <help@stably.ai>

* Checkpoint multi-host sidebar and project-first notes

Co-authored-by: Orca <help@stably.ai>

* Add project-first compatibility persistence

Co-authored-by: Orca <help@stably.ai>

* Expose project host setup APIs

Co-authored-by: Orca <help@stably.ai>

* Group sidebar rows by project setup

Co-authored-by: Orca <help@stably.ai>

* Document project-first host model discussion

Co-authored-by: Orca <help@stably.ai>

* Resolve workspace creation through project host setups

Co-authored-by: Orca <help@stably.ai>

* Stamp workspace ownership with project host setup

Co-authored-by: Orca <help@stably.ai>

* Add project host setup existing folder API

Co-authored-by: Orca <help@stably.ai>

* Summarize project-first host model discussion

Co-authored-by: Orca <help@stably.ai>

* Add project host setup CLI commands

Co-authored-by: Orca <help@stably.ai>

* Allow CLI worktree creation by project host setup

Co-authored-by: Orca <help@stably.ai>

* Add workspace host setup picker

Co-authored-by: Orca <help@stably.ai>

* Add project host setup settings summary

Co-authored-by: Orca <help@stably.ai>

* Make project host setup settings navigable

Co-authored-by: Orca <help@stably.ai>

* Stabilize project host setup settings selector

Co-authored-by: Orca <help@stably.ai>

* Add project host existing-folder setup form

Co-authored-by: Orca <help@stably.ai>

* Update project host model implementation status

Co-authored-by: Orca <help@stably.ai>

* Keep projects outermost in default sidebar view

Co-authored-by: Orca <help@stably.ai>

* Update project-first sidebar status

Co-authored-by: Orca <help@stably.ai>

* Show host context in project sidebar groups

Co-authored-by: Orca <help@stably.ai>

* Show unavailable hosts in workspace run target

Co-authored-by: Orca <help@stably.ai>

* Import missing project host from composer

Co-authored-by: Orca <help@stably.ai>

* Clone project host setup from composer

Co-authored-by: Orca <help@stably.ai>

* Persist project host setup method

Co-authored-by: Orca <help@stably.ai>

* Clone project hosts over SSH

Co-authored-by: Orca <help@stably.ai>

* Improve SSH clone cancellation cleanup

Co-authored-by: Orca <help@stably.ai>

* Backfill workspace project host ownership

Co-authored-by: Orca <help@stably.ai>

* Gate project host setup runtime capability

Co-authored-by: Orca <help@stably.ai>

* Preserve independent project host setups

Co-authored-by: Orca <help@stably.ai>

* Add project host setup update API

Co-authored-by: Orca <help@stably.ai>

* Add project host setup delete API

Co-authored-by: Orca <help@stably.ai>

* Add project host setup create API

Co-authored-by: Orca <help@stably.ai>

* Expose project host setup lifecycle in renderer store

Co-authored-by: Orca <help@stably.ai>

* Handle independent project host setups in settings

Co-authored-by: Orca <help@stably.ai>

* Add pending host setup action in project settings

Co-authored-by: Orca <help@stably.ai>

* Show pending project host setup status in composer

Co-authored-by: Orca <help@stably.ai>

* Report pending setup state in workspace target resolution

Co-authored-by: Orca <help@stably.ai>

* Use shared host registry for project setup choices

Co-authored-by: Orca <help@stably.ai>

* Add settings clone flow for project host setups

Co-authored-by: Orca <help@stably.ai>

* Gate unavailable project host setup options

Co-authored-by: Orca <help@stably.ai>

* Gate unavailable project setup hosts in settings

Co-authored-by: Orca <help@stably.ai>

* Stream SSH clone progress to renderer

Co-authored-by: Orca <help@stably.ai>

* Update project host model status notes

Co-authored-by: Orca <help@stably.ai>

* Add CLI project host setup clone command

Co-authored-by: Orca <help@stably.ai>

* Make add project host aware

Co-authored-by: Orca <help@stably.ai>

* Complete project host setup validation

Co-authored-by: Orca <help@stably.ai>

* Recover floating workspace terminal WebGL atlas on reopen (#5069)

Co-authored-by: Orca <help@stably.ai>

* Fix stale terminal daemon spawn health (#5064)

Co-authored-by: Orca <help@stably.ai>

* Suspend floating workspace terminal WebGL while the panel is closed (#5073)

Co-authored-by: Orca <help@stably.ai>

* Fix source control branch compare base (#5074)

Co-authored-by: Orca <help@stably.ai>

* Fix workspace-creation tour panel clipped by the Create Worktree dialog (#5078)

* Fix workspace-creation tour panel clipped by the composer dialog

The tour panel portals into dialog/sheet content that clips overflow, but
its position was clamped against the window viewport. With the Project
field spanning nearly the dialog's full width, the panel landed past the
dialog's right edge and overflow-hidden cut it down to a sliver. Clamp
hosted panels within the host's bounds instead, so the panel flips below
the target and stays fully visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add JSDoc docstrings to satisfy CodeRabbit docstring coverage check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Test hosted contextual tour overlay positioning

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* release: v1.4.56

* Handle buffer overflows gracefully and truncate diffs fairly (#5083)

- Gracefully fall back to file-name summaries when staged diffs exceed
  node/ssh execution maxBuffer limits, preventing generation failures.
- Split oversized diffs by file and allocate budget via water-filling,
  ensuring single huge files do not starve smaller human changes.
- Clip truncated diff sections on line boundaries to avoid half-lines.

* Wrap AI generation controls with tooltips and clean i18n dependencies (#5087)

- Wrap the AI generation button in a tooltip so users can see the
  disabled reason or the action description on hover.
- Add unit tests verifying tooltip triggers and aria-label safety.
- Simplify memo dependencies in settings metadata and worktree palette
  by using 'useTranslation()' to handle language-change rerenders
  directly without needing 'i18n.language'.

* fix: address review findings (#5088)

* Fix localization in repository hooks and base ref suggestion toast (#5089)

* Fix localization in base ref toast and custom hook description

- Localize the "commit"/"commits" plural nouns in the base ref toast.
- Translate missing suggestion toast strings for JA, KO, and ZH locales.
- Pass `{{artifact_url}}` as a literal template variable to translate
  calls to prevent i18next from treating it as a dynamic placeholder.

* Fix localization reactivity in RepositoryHooksSection

Move static variables containing translation calls into helper functions
and subscribe to translation updates using useTranslation. This ensures
that localized options, descriptions, and error messages refresh
dynamically when the user changes the UI language.

* Fix task page labels after language changes (#5086)

Co-authored-by: Orca <help@stably.ai>

* release: v1.4.57

* Fix automation tabs showing a shell instead of the live agent (#5099)

* Fix automation tabs showing a shell instead of the live agent

Opening a background automation's terminal tab showed a bare shell while
the agent (Claude) kept running headless — the sidebar updated but the
pane was attached to the wrong PTY.

On first mount the restored ptyId equals the tab ptyId, and
isSessionOwnedByWorktree() returns true for it, so connectPanePty routed
the still-live eagerly-spawned PTY into the daemon-reattach branch
(transport.connect({ sessionId })), which spawns a fresh shell and
orphans the live agent PTY instead of adopting it via attach()+replay.

Part A: gate the deferred reattach on the absence of a live eager buffer.
A live eager buffer means the PTY is a still-running local session to
adopt (attach + replay), not a daemon session to re-connect. Daemon
reattach and remote PTYs are unaffected (gated on the eager buffer).

Part B: publish never-mounted background automation tabs into the runtime
graph (gated on a live eager buffer) so the live agent PTY binds to its
real tab instead of surfacing as an orphan `pty:<id>` terminal — fixing
`orca terminal list`, the CLI, and automation session-reuse.

Adds a characterization test (fails on the old code, passes now) and a
runtime-graph publish test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden eager PTY tab adoption

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>

* Fix i18n label spacing in menus and settings (#5108)

* fix i18n label spacing

* Fix localized account runtime labels

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>

* Improve localization catalog sync workflow (#5110)

Co-authored-by: Orca <help@stably.ai>

* Add Warp terminal theme import (#4714)

Co-authored-by: Orca <help@stably.ai>

* release: v1.4.58

* Tidy README badge layout

* Handle integration credential decrypt failures (#4683)

Co-authored-by: Orca <help@stably.ai>

* Fix git repo telemetry for repo adds (#5121)

Co-authored-by: Orca <help@stably.ai>

* Add feature interaction usage bucket telemetry (#5119)

Co-authored-by: Orca <help@stably.ai>

* Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122)

Co-authored-by: Orca <help@stably.ai>

* perf(windows): fix 60s startup ACL walk and OpenCode streaming freeze, with benchmark harnesses (#5124)

* release: v1.4.59-rc.0

* Fix packaged shell PATH order (#5125)

Co-authored-by: Orca <help@stably.ai>

* Add Floating Workspace contextual tour (#5062)

* Add floating workspace contextual tour

Co-authored-by: Orca <help@stably.ai>

* Clarify floating workspace tour intro copy

Co-authored-by: Orca <help@stably.ai>

* Differentiate floating workspace tour steps instead of repeating examples

Co-authored-by: Orca <help@stably.ai>

* Lead floating workspace tour with the user benefit

Co-authored-by: Orca <help@stably.ai>

* Pitch floating workspace tour around cross-repo agents

Co-authored-by: Orca <help@stably.ai>

* Refine floating workspace tour step 1 copy

Co-authored-by: Orca <help@stably.ai>

* Anchor floating workspace tour step 2 on the minimize control

Co-authored-by: Orca <help@stably.ai>

* Restore floating workspace tour step 2

Co-authored-by: Orca <help@stably.ai>

* Anchor floating workspace tour steps on New Terminal and New Markdown Note

Co-authored-by: Orca <help@stably.ai>

* Retitle floating workspace tour step 2 as scratchpad

Co-authored-by: Orca <help@stably.ai>

* Add why-comments for tour selector fallback and placement flipping

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>

* Fix source control compare base ambiguity (#5127)

Co-authored-by: Orca <help@stably.ai>

* release: v1.4.59-rc.1 [rc-slot:2026-06-10-15]

* release: v1.4.59

* Default-driven create-project flow: name-first form with sensible defaults (#5115)

Co-authored-by: Orca <help@stably.ai>

* Redesign Connect integrations (#4531)

Co-authored-by: Orca <help@stably.ai>

* Expose E2E store via build mode

* File search match counts (#5085)

* Add matchCount to SearchFileResult for accurate per-file hit counts

Co-authored-by: Orca <help@stably.ai>

* Add file search match count design

* rm design doc

---------

Co-authored-by: Orca <help@stably.ai>

* fix: address review findings (#5139)

* perf(windows): avoid blocking daemon pid checks (#5137)

* release: v1.4.60-rc.0

* release: v1.4.60

* Preserve core workflow terms in English and apply CJK spacing (#5141)

* Preserve core workflow and product terms in English across locales

Update translation policy to prevent localization of key terms such as
"Agent", "Commit", "Markdown", and "Terminal". This ensures consistent
jargon and product branding.

Introduce CJK-Latin term spacing to keep these Latin terms legible
when combined with CJK text, while adjusting Korean particle spacing.
Also add overrides to prevent network proxy settings from being
mistranslated as "Agent".

* Preserve repo terminology in English and localize source control labels

Treat "repo" and "repos" (and their capitalized forms) as brand terms
that should remain in English/Latin across CJK and Spanish locales.
Update translation files and policies to replace translated words like
"repositorio" or "リポジトリ" with "repo"/"repos", and fix an issue where
latin brand terms could be incorrectly matched as substrings in larger
words during cleanup.

Additionally, externalize and localize the "Staged Changes", "Changes",
and "Untracked Files" section labels in the source control sidebar.

* UX (#5143)

* UX/copy tweaks (#5142)

* UX/copy tweaks

* UX/copy tweaks

* Fix missed star UI translations (#5148)

* fix: make windows ssh relay deploy survive session teardown (#5136)

* Add option to remove child projects when deleting repo groups (#4702)

Co-authored-by: Orca <help@stably.ai>

* fix: remove checks panel response badge (#5147)

* Add read-only `orca linear` CLI with trusted launch-prompt pointer (V1) (#5126)

Co-authored-by: Orca <help@stably.ai>

* Add AI Vault session history

## Summary
- add AI Vault session scanning and resume command construction
- add the Agents sidebar panel with filtering, grouping, copy/open actions, and local resume launch
- support dragging saved sessions onto terminal split panes

## Validation
- pnpm run lint
- pnpm run typecheck
- pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/register-core-handlers.test.ts src/main/ai-vault/session-scanner.test.ts src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts src/renderer/src/lib/ai-vault-session-drag.test.ts src/renderer/src/lib/launch-ai-vault-session.test.ts

* Default agent launches to yolo permissions mode (#5145)

* Default agent launches to yolo mode

* test: update launch default validations

* Fix Claude usage refresh error copy (#5155)

Co-authored-by: Orca <help@stably.ai>

* Move workspace board to sidebar bottom toolbar (#5146)

Co-authored-by: Orca <help@stably.ai>

* Rebuild contextual tour positioning on floating-ui; fix hosted dialog placement and arrow seam (#5154)

Co-authored-by: Orca <help@stably.ai>

* Fix missing spaces in cross-repo switch dialog (#5158)

* Fix Ctrl+Tab switcher selection on release (#5116)

* Fix additional i18n spacing regressions from #4995 (#5159)

* Refine add project selection styling (#5160)

Co-authored-by: Orca <help@stably.ai>

* improve chinese localization (#5162)

* Fix floating workspace needing two clicks after app switch (macOS) (#5128)

* Autofocus feedback textarea when Send Feedback dialog opens (#5164)

* fix: address pr-bug-scan validated finding from #4683 (#5151)

Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>

* fix: enable claude agent teams by default (#5168)

* Refresh Jira and Linear status after credential errors (#5169)

* fix: address pr-bug-scan validated finding from #4683

Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces

* Refresh Jira and Linear status to clear stale credential errors

Ensure stale credential decryption errors are cleared from the store
status once a successful API read completes. By updating the check in
shouldRefreshStatusAfterRead to trigger when a credentialError is
currently set, successful issue or list fetches will trigger a status
check and remove stale error flags.

---------

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>

* Hide internal context from AI Vault titles (#5175)

* Fix detached HEAD publish actions (#5173)

* Keep freshly split terminal pane mounted if newborn PTY exits early (#5171)

Prevent a newly split pane from collapsing immediately if its PTY exits
during initial setup before any output is received or input is sent.
This ensures a failed startup session remains visible to the user.

* Route task PR queries by upstream source (#5176)

* Route task PR queries by upstream source

Implements the routing described in docs/tasks-pr-upstream-source.md so task PR and issue queries stay scoped to the selected source.

* rm design doc

* Prevent stale PR refreshes from restoring unlinked review state (#5180)

- Pass `worktreeId` to `fetchPRForBranch` to track active worktree context
- Ignore inflight or queued PR fetches if the worktree has been unlinked
- Include linked PR/MR metadata in the checks panel snapshot key to trigger updates immediately on link/unlink events

* Fix Claude agents management status detection (#5179)

Co-authored-by: Orca <help@stably.ai>

* fix: address review findings (#5177)

* Allow resolving selected review comments with AI (#5184)

* Allow resolving selected PR/MR review comments with AI

Users can now select specific unresolved review comments or threads in
the Checks panel sidebar, queue them, and trigger an AI agent to address
them, marking resolved threads on the host upon agent launch.

- Adds checkboxes and action/send buttons to select and queue comments.
- Builds a structured, robust prompt with sanitized comment metadata.
- Optimistically marks threads resolved on launch with rollback on error.
- Supports both GitHub PRs and GitLab MRs.

* Consolidate PR comment selection state and eliminate effects

Combine independent selection states and context-tracking into a single
state object. Derive active selection data and prune ineligible comments
during render using useMemo instead of relying on asynchronous
useEffect synchronization hooks.

* Improve source control action dialog layout and recipe saving UX (#5153)

* Improve source control agent action dialog layout and recipe UX

- Constrain dialog and scroll area heights to prevent viewport overflow.
- Add variable chips to easily insert the base prompt with tooltip previews.
- Keep the recipe save controls visible when a recipe is already saved, showing informational status text instead of hiding them.
- Update localized copy across multiple languages and reduce textarea rows.
- Add unit tests for the variable chip preview and save target visibility.

* Fix recipe-saved check in source control action dialog

* Evaluate only the selected save target instead of checking all available targets, as the action only writes to the selected target.
* Update daemon PTY adapter test fake PID to prevent collision with real host OS processes during runtime directory lookups.

* fix: remove unsupported agent launch defaults (#5185)

* Update Chinese and Japanese translations for worktrees and fixes (#5187)

- Correct awkward Chinese translation of "fix" ("使固定") to "修复" and "基本的" to "主工作树" (main worktree).
- Improve Japanese translation of "fix" from physical repair ("修理") to software correction ("修正").

* Embed hosted review creation composer directly in Checks panel (#5140)

* Embed hosted review creation composer directly in the Checks panel

- Replaces the modal pull request/merge request creation dialog with an
  inline composer embedded in the empty state of the Checks sidebar.
- Extracts and moves pull request generation state to a dedicated store
  slice so AI-generated details are persisted across sidebar unmounts.

* Fix hosted review composer feedback

* Combine file search and file explorer right sidebar tabs (#5182)

Unifies file discovery and tree navigation under a single Explorer domain, simplifying the right sidebar activity bar and reducing tab clutter.

* Replaces the standalone 'search' activity bar tab with a nested 'search' subview inside the File Explorer tab
* Introduces 'rightSidebarExplorerView' ('files' | 'search') state to manage the active subview inside the Explorer
* Adds a search button to the File Explorer toolbar and a back button to the search subview for seamless transition
* Exposes 'showRightSidebarFiles' and 'showRightSidebarSearch' store actions to route and seed search queries/include patterns
* Adapts file explorer keybindings, git status polling, and external workspace watchers to respect the active subview
* Maps legacy persisted search tab state to the new explorer search view for backward compatibility

* release: v1.4.61-rc.1

* Add multi-repo folder workspaces (v1) (#5172)

Co-authored-by: Orca <help@stably.ai>

* release: v1.4.61-rc.2

* Hide unavailable project hosts in worktree composer

Co-authored-by: Orca <help@stably.ai>

* Remove inline project host setup from composer

Co-authored-by: Orca <help@stably.ai>

* Mark imported project host setup methods

Co-authored-by: Orca <help@stably.ai>

* Fix rebase merge fallout

Co-authored-by: Orca <help@stably.ai>

* Disable unavailable Add Project hosts

Co-authored-by: Orca <help@stably.ai>

* Compact Add Project host selector

Co-authored-by: Orca <help@stably.ai>

* Hide redundant SSH target chooser

Co-authored-by: Orca <help@stably.ai>

* Browse SSH clone destinations

Co-authored-by: Orca <help@stably.ai>

* Avoid local clone defaults for SSH hosts

Co-authored-by: Orca <help@stably.ai>

* Polish host-aware Add Project flows

Co-authored-by: Orca <help@stably.ai>

* Polish remote host add project flows

Co-authored-by: Orca <help@stably.ai>

* Remove redundant host kind chips

Co-authored-by: Orca <help@stably.ai>

* Fix remote project setup UX gaps

Co-authored-by: Orca <help@stably.ai>

* Fix multihost workspace composer project identity

Co-authored-by: Orca <help@stably.ai>

* Finish host context merge repair

Co-authored-by: Orca <help@stably.ai>

* Continue host context checklist implementation

Co-authored-by: Orca <help@stably.ai>

* Route Linear and Jira tasks by source context

Co-authored-by: Orca <help@stably.ai>

* Preserve Linear task source context in history

Co-authored-by: Orca <help@stably.ai>

* Scope task retry state by source context

Co-authored-by: Orca <help@stably.ai>

* Route GitHub drawer reads by source context

Co-authored-by: Orca <help@stably.ai>

* Guard GitLab selectors with repo context

Co-authored-by: Orca <help@stably.ai>

* Guard GitHub metadata selectors

Co-authored-by: Orca <help@stably.ai>

* Route GitHub task row actions by source context

Co-authored-by: Orca <help@stably.ai>

* Update GitHub source-context checklist status

Co-authored-by: Orca <help@stably.ai>

* Show host ownership for CLI provider accounts

Co-authored-by: Orca <help@stably.ai>

* Persist GitLab task detail source context

Co-authored-by: Orca <help@stably.ai>

* Show host scope for provider API budgets

Co-authored-by: Orca <help@stably.ai>

* Preserve Jira task source context

Co-authored-by: Orca <help@stably.ai>

* Scope Jira optimistic task patches

Co-authored-by: Orca <help@stably.ai>

* Resolve task PR bases on run host

Co-authored-by: Orca <help@stably.ai>

* Record Jira task workspace usage

Co-authored-by: Orca <help@stably.ai>

* Scope Linear optimistic task patches

Co-authored-by: Orca <help@stably.ai>

* Scope GitHub optimistic task patches

Co-authored-by: Orca <help@stably.ai>

* Clean host copy in onboarding flows

Co-authored-by: Orca <help@stably.ai>

* Preserve automation CLI run context

Co-authored-by: Orca <help@stably.ai>

* Add automation CLI source context selector

Co-authored-by: Orca <help@stably.ai>

* Clarify unavailable task source hosts

Co-authored-by: Orca <help@stably.ai>

* Surface host model runtime capability skew

Co-authored-by: Orca <help@stably.ai>

* Use SSH host copy in reconnect dialog

Co-authored-by: Orca <help@stably.ai>

* Show host context in task source picker

Co-authored-by: Orca <help@stably.ai>

* Mark task source display complete

Co-authored-by: Orca <help@stably.ai>

* Clarify provider account host selection

Co-authored-by: Orca <help@stably.ai>

* Guard task source switching boundary

Co-authored-by: Orca <help@stably.ai>

* Mark task source diagnostics persisted

Co-authored-by: Orca <help@stably.ai>

* Mark base resolution host boundary

Co-authored-by: Orca <help@stably.ai>

* Clarify external automation source states

Co-authored-by: Orca <help@stably.ai>

* Harden project host compatibility projection

Co-authored-by: Orca <help@stably.ai>

* Finish host copy audit

Co-authored-by: Orca <help@stably.ai>

* Add provider host scope controls

Co-authored-by: Orca <help@stably.ai>

* Show task source account labels

Co-authored-by: Orca <help@stably.ai>

* Show automation run context in CLI

Co-authored-by: Orca <help@stably.ai>

* Scope Jira task cache lookups by source

Co-authored-by: Orca <help@stably.ai>

* Seed workspace creation from task source context

Co-authored-by: Orca <help@stably.ai>

* Explain disabled external automation actions

Co-authored-by: Orca <help@stably.ai>

* Surface task source runtime capability gaps

Co-authored-by: Orca <help@stably.ai>

* Persist automation run context from UI saves

Co-authored-by: Orca <help@stably.ai>

* Require workspace run capability for setup hosts

Co-authored-by: Orca <help@stably.ai>

* Disable automation runs for stale host setup

Co-authored-by: Orca <help@stably.ai>

* Route GitHub drawer metadata by source host

Co-authored-by: Orca <help@stably.ai>

* Guard runtime project setup mutations by host model

Co-authored-by: Orca <help@stably.ai>

* Route PR page metadata by repo host

Co-authored-by: Orca <help@stably.ai>

* Route PR mention metadata by repo host

Co-authored-by: Orca <help@stably.ai>

* Route GitHub Project edits by view source

Co-authored-by: Orca <help@stably.ai>

* Clarify runtime automation disabled states

Co-authored-by: Orca <help@stably.ai>

* Guard runtime automation backend dispatch

Co-authored-by: Orca <help@stably.ai>

* Preserve GitLab task source identity

Co-authored-by: Orca <help@stably.ai>

* Remove redundant SSH target row in add project

Co-authored-by: Orca <help@stably.ai>

* Add task source provider availability reasons

Co-authored-by: Orca <help@stably.ai>

* Surface task provider preflight availability

Co-authored-by: Orca <help@stably.ai>

* Record local GitHub task source verification

Co-authored-by: Orca <help@stably.ai>

* Record Linear task source verification

Co-authored-by: Orca <help@stably.ai>

* Show automation source context in details

Co-authored-by: Orca <help@stably.ai>

* Record remote capability negotiation coverage

Co-authored-by: Orca <help@stably.ai>

* Record local add project create verification

Co-authored-by: Orca <help@stably.ai>

* Scope Linear cached task reads by source

Co-authored-by: Orca <help@stably.ai>

* Preserve PR generation host ownership

Co-authored-by: Orca <help@stably.ai>

* Route git operations by owner host

Co-authored-by: Orca <help@stably.ai>

* Route delete warnings by worktree owner

Co-authored-by: Orca <help@stably.ai>

* Route editor drops by worktree owner

Co-authored-by: Orca <help@stably.ai>

* Route agent draft paste by tab owner

Co-authored-by: Orca <help@stably.ai>

* Route file explorer requests by worktree owner

Co-authored-by: Orca <help@stably.ai>

* Document remaining host context gaps

Co-authored-by: Orca <help@stably.ai>

* Check runtime task source provider auth

Co-authored-by: Orca <help@stably.ai>

* Validate automation source availability

Co-authored-by: Orca <help@stably.ai>

* Route remaining UI requests by owner host

Co-authored-by: Orca <help@stably.ai>

* Route quick open file listing by worktree owner

Co-authored-by: Orca <help@stably.ai>

* Route typed GitHub lookups by source host

Co-authored-by: Orca <help@stably.ai>

* Centralize automation run identity fallback

Co-authored-by: Orca <help@stably.ai>

* Surface unsupported task source providers

Co-authored-by: Orca <help@stably.ai>

* Document automation legacy repo compatibility

Co-authored-by: Orca <help@stably.ai>

* Record live host model verification

Co-authored-by: Orca <help@stably.ai>

* Quiet disconnected SSH polling

Co-authored-by: Orca <help@stably.ai>

* Verify task drawer source boundaries

Co-authored-by: Orca <help@stably.ai>

* Verify GitLab repo source selectors

Co-authored-by: Orca <help@stably.ai>

* Route automations through owning host

Co-authored-by: Orca <help@stably.ai>

* Update host context verification checklist

Co-authored-by: Orca <help@stably.ai>

* Run remote automations headlessly in serve mode

Co-authored-by: Orca <help@stably.ai>

* Keep setup guide entry stable during refresh

Co-authored-by: Orca <help@stably.ai>

* Keep setup script prompt stable during host switches

Co-authored-by: Orca <help@stably.ai>

* Deduplicate Tasks project picker sources

Co-authored-by: Orca <help@stably.ai>

* Use project identity for Tasks picker dedupe

Co-authored-by: Orca <help@stably.ai>

* Add Tasks source host switcher

Co-authored-by: Orca <help@stably.ai>

* Refine Tasks source picker disclosure

Co-authored-by: Orca <help@stably.ai>

* Polish Tasks source picker hover

Co-authored-by: Orca <help@stably.ai>

* Open Tasks source menu on hover

Co-authored-by: Orca <help@stably.ai>

* Match Tasks source submenu hover behavior

Co-authored-by: Orca <help@stably.ai>

* Open Tasks source submenu from project row hover

Co-authored-by: Orca <help@stably.ai>

* Group automation project hosts

Co-authored-by: Orca <help@stably.ai>

* Tighten automation project picker density

Co-authored-by: Orca <help@stably.ai>

* Show selected host in Tasks project picker

Co-authored-by: Orca <help@stably.ai>

* Hide host labels for single-host project pickers

Co-authored-by: Orca <help@stably.ai>

* Use saved remote server names in host pickers

Co-authored-by: Orca <help@stably.ai>

* Use standard add project start for remote servers

Co-authored-by: Orca <help@stably.ai>

* Use saved host labels in workspace surfaces

Co-authored-by: Orca <help@stably.ai>

* Route remote browser tabs through runtime hosts

Co-authored-by: Orca <help@stably.ai>

* Keep sidebar project-first across grouping modes

Co-authored-by: Orca <help@stably.ai>

* Polish multi-host remote runtime UX

Co-authored-by: Orca <help@stably.ai>

* Fix CI lint and remove design notes

Co-authored-by: Orca <help@stably.ai>

* Fix CI test failures

Co-authored-by: Orca <help@stably.ai>

* Fix Windows CLI path expectation

Co-authored-by: Orca <help@stably.ai>

* Fix CI renderer test expectations

Co-authored-by: Orca <help@stably.ai>

* Fix remaining verify test failures

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Bryant Ung <bryant.ung@outlook.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Borja <3930245+BorjaLL@users.noreply.github.com>
Co-authored-by: Parker Rex <me@parkerrex.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com>
Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
2026-06-13 18:53:01 -07:00

1163 lines
42 KiB
TypeScript

/* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward
broadcasting, and session lifecycle in one file to keep the data flow obvious. */
import { ipcMain, powerMonitor, type BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { SshConnectionStore } from '../ssh/ssh-connection-store'
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SshRelaySession } from '../ssh/ssh-relay-session'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import {
type DetectedPort,
type EnrichedDetectedPort,
type SavedPortForward,
type SshTarget,
type SshConnectionStatus,
type SshConnectionState
} from '../../shared/ssh-types'
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
import { isAuthError } from '../ssh/ssh-connection-utils'
import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset'
import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { registerSshBrowseHandler } from './ssh-browse'
import {
getConnectionIdsForWorktree,
enrichSshDetectedPorts,
enrichSshForwardEntries,
getWorktreeIdsForConnection
} from '../ports/ssh-advertised-url-enrichment'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { requestCredential, registerCredentialHandler } from './ssh-passphrase'
import {
clearProviderPtyState,
deletePtyOwnership,
getPtyIdsForConnection,
getSshPtyProvider
} from './pty'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
let portForwardManager: SshPortForwardManager | null = null
let registeredConnectSshTarget: ((targetId: string) => Promise<SshConnectionState>) | null = null
let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null
let persistedStore: Store | null = null
let advertisedUrlWatcherUnsubscribe: (() => void) | null = null
let powerMonitorUnsubscribe: (() => void) | null = null
let currentGetMainWindow: () => BrowserWindow | null = () => null
let currentRuntime: OrcaRuntimeService | undefined
const SSH_IPC_CHANNELS = [
'ssh:listTargets',
'ssh:addTarget',
'ssh:updateTarget',
'ssh:removeTarget',
'ssh:importConfig',
'ssh:connect',
'ssh:disconnect',
'ssh:terminateSessions',
'ssh:resetRelay',
'ssh:getState',
'ssh:needsPassphrasePrompt',
'ssh:testConnection',
'ssh:addPortForward',
'ssh:updatePortForward',
'ssh:removePortForward',
'ssh:listPortForwards',
'ssh:listDetectedPorts'
] as const
// Why: connection callbacks are process-lifetime; keeping this set outside
// registerSshHandlers prevents in-flight connects from splitting credential
// tracking when a BrowserWindow is recreated.
const credentialRequestedForTarget = new Set<string>()
function getCurrentMainWindow(): BrowserWindow | null {
return currentGetMainWindow()
}
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
if (!registeredConnectSshTarget) {
throw new Error('ssh_handlers_not_registered')
}
return registeredConnectSshTarget(targetId)
}
export function getRegisteredSshState(targetId: string): SshConnectionState | undefined {
return registeredGetSshState?.(targetId)
}
// Why: one session per SSH target encapsulates the entire relay lifecycle
// (multiplexer, providers, abort controller, state machine). Eliminates the
// scattered Maps/Sets that previously tracked this state independently.
const activeSessions = new Map<string, SshRelaySession>()
function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined {
return target?.relayGracePeriodSeconds
}
// Why: multiple renderer tabs for the same SSH target can fire ssh:connect
// concurrently. Without serialization, the second call interleaves with the
// first — both see no existing session, both create one, and the first one
// leaks. This map holds the in-flight connect promise so the second call
// awaits the first rather than racing.
const connectInFlight = new Map<string, Promise<SshConnectionState>>()
// Why: reset tears down and force-stops the relay, then disconnects SSH.
// Publish that lifecycle so new connects and duplicate resets cannot race it.
const resetRelayInFlight = new Map<string, Promise<void>>()
// Why: ssh:testConnection calls connect() then disconnect(), which fires
// state-change events to the renderer. This causes worktree cards to briefly
// flash "connected" then "disconnected". Suppressing broadcasts during tests
// avoids that visual glitch.
const testingTargets = new Set<string>()
// Why: when a relay channel keeps dying (e.g. a stale --connect bridge keeps
// being replaced, a remote-side bug closes the channel right after handshake,
// or a mismatched relay binary refuses every handshake), the unguarded
// _onRelayLost handler reconnects as fast as the network allows, hammering
// both the local main process and the remote sshd in a tight loop. Track
// per-target reconnect attempts and apply exponential backoff so the loop
// terminates with a recoverable error instead of running forever. Successful
// `ready` resets the attempt counter for the next genuine drop.
type RelayLostBackoffState = {
attempts: number
lastAttemptStartedAt: number
pendingTimer: ReturnType<typeof setTimeout> | null
}
const relayLostBackoff = new Map<string, RelayLostBackoffState>()
const relayStateOverrides = new Map<string, SshConnectionState>()
const RELAY_LOST_MAX_ATTEMPTS = 6
const RELAY_LOST_BASE_DELAY_MS = 500
const RELAY_LOST_MAX_DELAY_MS = 15_000
// Why: if a fresh reconnect's mux dies within this window, the new session
// never stabilized (a flap, not a real recovery). Without this clamp the
// attempt counter would be reset prematurely by a mux that "reached ready"
// only on paper. 5 seconds covers normal post-deploy provider registration
// and PTY reattach without being so generous that a real long-lived session
// looks like a flap.
const RELAY_LOST_STABILIZED_MS = 5_000
function clearRelayLostBackoff(targetId: string): void {
const state = relayLostBackoff.get(targetId)
if (state?.pendingTimer) {
clearTimeout(state.pendingTimer)
}
relayLostBackoff.delete(targetId)
}
function broadcastSshState(
getMainWindow: () => BrowserWindow | null,
targetId: string,
state: SshConnectionState
): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', { targetId, state })
}
}
function publishRelayOverride(
getMainWindow: () => BrowserWindow | null,
targetId: string,
status: SshConnectionStatus,
error: string | null,
reconnectAttempt: number
): void {
const state: SshConnectionState = { targetId, status, error, reconnectAttempt }
relayStateOverrides.set(targetId, state)
broadcastSshState(getMainWindow, targetId, state)
}
function clearRelayStateOverride(targetId: string): void {
relayStateOverrides.delete(targetId)
}
function getPublicSshState(targetId: string): SshConnectionState | undefined {
return relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) ?? undefined
}
function broadcastPortForwards(getMainWindow: () => BrowserWindow | null, targetId: string): void {
const win = getMainWindow()
if (!win || win.isDestroyed()) {
return
}
win.webContents.send('ssh:port-forwards-changed', {
targetId,
forwards: listForwardsEnriched(targetId)
})
}
function broadcastDetectedPorts(
getMainWindow: () => BrowserWindow | null,
targetId: string,
ports: DetectedPort[],
options?: Parameters<typeof enrichSshDetectedPorts>[3]
): void {
const win = getMainWindow()
if (!win || win.isDestroyed()) {
return
}
win.webContents.send('ssh:detected-ports-changed', {
targetId,
ports: enrichDetected(targetId, ports, options)
})
}
function listForwardsEnriched(targetId: string): ReturnType<SshPortForwardManager['listForwards']> {
const raw = portForwardManager!.listForwards(targetId)
if (!persistedStore) {
return raw
}
return enrichSshForwardEntries(raw, getWorktreeIdsForConnection(persistedStore, targetId))
}
function enrichDetected(
targetId: string,
ports: DetectedPort[],
options?: Parameters<typeof enrichSshDetectedPorts>[3]
): EnrichedDetectedPort[] {
if (!persistedStore) {
return ports
}
return enrichSshDetectedPorts(
ports,
getWorktreeIdsForConnection(persistedStore, targetId),
undefined,
options
)
}
// Why: after user-initiated add/remove/update the runtime manager is the
// single source of truth — write exactly its entries and nothing else.
// A separate helper (persistPortForwardsWithUnrestored) preserves entries
// that failed to restore so they retry on next reconnect.
function persistPortForwards(targetId: string): void {
const active = portForwardManager!.listForwards(targetId)
const saved: SavedPortForward[] = active.map((f) => ({
localPort: f.localPort,
remoteHost: f.remoteHost,
remotePort: f.remotePort,
label: f.label
}))
sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined })
}
// Why: called after restorePortForwards so that forwards which failed to
// restore (e.g. port temporarily busy) are kept in the persisted list and
// retried on next reconnect, rather than being silently dropped.
function persistPortForwardsWithUnrestored(targetId: string): void {
const active = portForwardManager!.listForwards(targetId)
const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`))
const existing = sshStore!.getTarget(targetId)?.portForwards ?? []
const unrestored = existing.filter(
(pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`)
)
const saved: SavedPortForward[] = [
...active.map((f) => ({
localPort: f.localPort,
remoteHost: f.remoteHost,
remotePort: f.remotePort,
label: f.label
})),
...unrestored
]
sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined })
}
async function restorePortForwards(
targetId: string,
getMainWindow: () => BrowserWindow | null
): Promise<void> {
const target = sshStore!.getTarget(targetId)
if (!target?.portForwards?.length) {
return
}
const conn = connectionManager!.getConnection(targetId)
if (!conn) {
return
}
// Why: don't prune failed restores from persisted state. A failure may
// be transient (e.g. port temporarily busy at startup) and the forward
// should be retried on the next reconnect rather than silently deleted.
for (const saved of target.portForwards) {
// Why: if the session disconnects/reconnects while this loop is running,
// a new connection object is created. Checking identity avoids adding
// forwards against a stale connection, which would leak local listeners
// that the next reconnect's removeAllForwards() doesn't know about.
if (connectionManager!.getConnection(targetId) !== conn) {
return
}
try {
await portForwardManager!.addForward(
targetId,
conn,
saved.localPort,
saved.remoteHost,
saved.remotePort,
saved.label
)
} catch (err) {
console.warn(
`[ssh] Failed to restore forward :${saved.localPort} → ${saved.remoteHost}:${saved.remotePort}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
persistPortForwardsWithUnrestored(targetId)
broadcastPortForwards(getMainWindow, targetId)
}
function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null): void {
advertisedUrlWatcherUnsubscribe?.()
// Why: SSH port scans only emit when raw host/port/PID data changes. A
// terminal can print the advertised URL after the raw port row is already
// visible, so the watcher must also trigger a renderer refresh.
advertisedUrlWatcherUnsubscribe = advertisedUrlWatcher.onDidChange(({ worktreeId }) => {
if (!persistedStore) {
return
}
for (const targetId of getConnectionIdsForWorktree(persistedStore, worktreeId)) {
const session = activeSessions.get(targetId)
if (!session) {
continue
}
const scanner = session.getPortScanner()
if (scanner) {
// Why: watcher changes can arrive before the next SSH scan refreshes
// listener PIDs; cached scanner rows must not pin a fresh URL stale.
broadcastDetectedPorts(getMainWindow, targetId, scanner.getDetectedPorts(targetId), {
validatePid: false
})
}
broadcastPortForwards(getMainWindow, targetId)
}
})
}
function registerPowerMonitorReconnect(): void {
powerMonitorUnsubscribe?.()
const onSuspend = (): void => {
for (const session of activeSessions.values()) {
session.prepareForHostSleep()
}
}
const onResume = (): void => {
for (const targetId of activeSessions.keys()) {
const conn = connectionManager?.getConnection(targetId)
if (!conn) {
continue
}
const reconnect = connectionManager?.reconnect(targetId)
void reconnect?.catch((err) => {
console.warn(
`[ssh] Failed to reconnect ${targetId} after system resume: ${
err instanceof Error ? err.message : String(err)
}`
)
})
}
}
powerMonitor.on('suspend', onSuspend)
powerMonitor.on('resume', onResume)
powerMonitorUnsubscribe = () => {
powerMonitor.off('suspend', onSuspend)
powerMonitor.off('resume', onResume)
}
}
function createSshConnectionCallbacks(): SshConnectionCallbacks {
return {
onCredentialRequest: (targetId, kind, detail) => {
credentialRequestedForTarget.add(targetId)
return requestCredential(getCurrentMainWindow, targetId, kind, detail)
},
onStateChange: (targetId: string, state: SshConnectionState) => {
if (testingTargets.has(targetId)) {
return
}
// Why: when SSH reconnects after a network blip, we must re-deploy the
// relay and rebuild the full provider stack. The session's state machine
// ensures this only triggers when appropriate — 'deploying' state from
// an explicit ssh:connect is not 'ready', so this branch won't fire.
const session = activeSessions.get(targetId)
const sessionState = session?.getState()
const shouldReconnectRelay =
session !== undefined &&
state.status === 'connected' &&
state.reconnectAttempt === 0 &&
(sessionState === 'ready' || sessionState === 'reconnecting')
if (shouldReconnectRelay) {
// Why: SSH is connected before the relay providers are rebuilt. Keep
// renderer actions gated until SshRelaySession reaches ready again.
publishRelayOverride(
getCurrentMainWindow,
targetId,
'reconnecting',
'Relay channel reconnecting...',
state.reconnectAttempt
)
} else {
clearRelayStateOverride(targetId)
broadcastSshState(getCurrentMainWindow, targetId, state)
}
if (!session) {
return
}
// Why: allow reconnect from both 'ready' (normal network blip) and
// 'reconnecting' (previous reconnect attempt failed, e.g. relay deploy
// error on a working SSH connection). Without the 'reconnecting' check,
// a failed relay deploy would permanently brick the session.
if (shouldReconnectRelay) {
const target = sshStore?.getTarget(targetId)
const conn = connectionManager?.getConnection(targetId)
if (conn) {
void session.reconnect(conn, relayGracePeriodForTarget(target))
}
}
}
}
}
function broadcastDetectedPortsFromCurrentWindow(
targetId: string,
ports: DetectedPort[],
_platform: string
): void {
broadcastDetectedPorts(getCurrentMainWindow, targetId, ports)
}
function configureRelaySessionCallbacks(session: SshRelaySession): void {
session.setOnTerminalRelayError((tid, err) => {
clearRelayLostBackoff(tid)
console.warn(
`[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.`
)
publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0)
})
session.setOnRelayLost((tid) => {
const s = activeSessions.get(tid)
if (!s) {
return
}
const c = connectionManager?.getConnection(tid)
if (!c) {
return
}
const t = sshStore?.getTarget(tid)
// Why: bounded exponential backoff. Without this, a remote-side bug
// that closes every fresh --connect channel turns into an infinite
// tight loop spawning relay deploys until the user force-quits.
const state = relayLostBackoff.get(tid) ?? {
attempts: 0,
lastAttemptStartedAt: 0,
pendingTimer: null
}
if (state.pendingTimer) {
return
}
if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) {
console.warn(
`[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.`
)
relayLostBackoff.delete(tid)
// Why: surface the failure so the renderer can prompt the user.
// A still-live SSH connection with a dead relay is otherwise an
// invisible failure — typing in remote terminals just stops working.
publishRelayOverride(
getCurrentMainWindow,
tid,
'error',
'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.',
0
)
return
}
const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS)
state.attempts += 1
publishRelayOverride(
getCurrentMainWindow,
tid,
'reconnecting',
'Relay channel lost. Reconnecting...',
state.attempts
)
state.pendingTimer = setTimeout(() => {
state.pendingTimer = null
state.lastAttemptStartedAt = Date.now()
relayLostBackoff.set(tid, state)
const liveConn = connectionManager?.getConnection(tid)
if (!liveConn || !activeSessions.has(tid)) {
return
}
void s.reconnect(liveConn, relayGracePeriodForTarget(t))
}, delay)
relayLostBackoff.set(tid, state)
console.warn(
`[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms`
)
})
// Why: fires after both establish() and reconnect() reach 'ready'.
// Re-creates persisted port forwards so they survive app restarts
// and network blips without manual re-configuration.
session.setOnReady((tid) => {
const state = relayLostBackoff.get(tid)
if (state) {
const stabilized =
state.lastAttemptStartedAt === 0 ||
Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS
if (stabilized) {
relayLostBackoff.delete(tid)
}
}
clearRelayStateOverride(tid)
if (!testingTargets.has(tid)) {
broadcastSshState(getCurrentMainWindow, tid, {
targetId: tid,
status: 'connected',
error: null,
reconnectAttempt: 0
})
}
void restorePortForwards(tid, getCurrentMainWindow)
})
}
function refreshActiveRelaySessions(): void {
if (!persistedStore || !portForwardManager) {
return
}
for (const session of activeSessions.values()) {
session.refreshEnvironment(
getCurrentMainWindow,
persistedStore,
portForwardManager,
currentRuntime,
broadcastDetectedPortsFromCurrentWindow
)
configureRelaySessionCallbacks(session)
}
}
export function registerSshHandlers(
store: Store,
getMainWindow: () => BrowserWindow | null,
runtime?: OrcaRuntimeService
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
// Why: on macOS, app re-activation creates a new BrowserWindow and re-calls
// this function. ipcMain.handle() throws if a handler is already registered,
// so we must remove any prior handlers before re-registering.
for (const ch of SSH_IPC_CHANNELS) {
ipcMain.removeHandler(ch)
}
currentGetMainWindow = getMainWindow
currentRuntime = runtime
sshStore = new SshConnectionStore(store)
persistedStore = store
registerAdvertisedUrlRefresh(getCurrentMainWindow)
registerCredentialHandler(getCurrentMainWindow)
const callbacks = createSshConnectionCallbacks()
if (connectionManager) {
connectionManager.setCallbacks(callbacks)
} else {
connectionManager = new SshConnectionManager(callbacks)
}
portForwardManager ??= new SshPortForwardManager()
refreshActiveRelaySessions()
registerPowerMonitorReconnect()
registerSshBrowseHandler(() => connectionManager)
// ── Target CRUD ────────────────────────────────────────────────────
ipcMain.handle('ssh:listTargets', () => {
return sshStore!.listTargets()
})
ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit<SshTarget, 'id'> }) => {
return sshStore!.addTarget(args.target)
})
ipcMain.handle(
'ssh:updateTarget',
(_event, args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }) => {
return sshStore!.updateTarget(args.id, args.updates)
}
)
ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => {
const session = activeSessions.get(args.id)
if (session) {
// Why: removing a target is destructive. Tear down the live relay before
// deleting metadata so callbacks cannot keep using an orphan target id.
await portForwardManager!.removeAllForwards(args.id)
session.dispose()
activeSessions.delete(args.id)
clearRelayLostBackoff(args.id)
clearRelayStateOverride(args.id)
}
try {
await connectionManager!.disconnect(args.id)
} catch (err) {
console.warn(
`[ssh] Failed to disconnect removed target ${args.id}: ${err instanceof Error ? err.message : String(err)}`
)
}
persistedStore!.removeSshRemotePtyLeases(args.id)
sshStore!.removeTarget(args.id)
})
ipcMain.handle('ssh:importConfig', () => {
return sshStore!.importFromSshConfig()
})
// ── Connection lifecycle ───────────────────────────────────────────
async function connectTarget(targetId: string): Promise<SshConnectionState> {
const reset = resetRelayInFlight.get(targetId)
if (reset) {
await reset
}
// Why: serialize concurrent ssh:connect calls for the same target.
// Multiple tabs can fire connect simultaneously; without this, they
// interleave and the first session leaks.
const existing = connectInFlight.get(targetId)
if (existing) {
return existing
}
const promise = doConnect(targetId)
connectInFlight.set(targetId, promise)
try {
return await promise
} finally {
connectInFlight.delete(targetId)
}
}
registeredConnectSshTarget = connectTarget
registeredGetSshState = (targetId: string) => getPublicSshState(targetId)
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
return connectTarget(args.targetId)
})
async function doConnect(targetId: string): Promise<SshConnectionState> {
const target = sshStore!.getTarget(targetId)
if (!target) {
throw new Error(`SSH target "${targetId}" not found`)
}
const existingSession = activeSessions.get(targetId)
const existingState = connectionManager!.getState(targetId)
const existingMux = existingSession?.getMux()
if (
existingSession?.getState() === 'ready' &&
existingState?.status === 'connected' &&
connectionManager!.getConnection(targetId) &&
existingMux &&
!existingMux.isDisposed() &&
!relayStateOverrides.has(targetId) &&
!relayLostBackoff.has(targetId)
) {
// Why: BrowserWindow reactivation reruns renderer startup, which calls
// ssh:connect for already-live targets. Treat that as a refresh instead
// of tearing down the relay and stranding active port forwards.
broadcastSshState(getCurrentMainWindow, targetId, existingState)
return existingState
}
clearRelayStateOverride(targetId)
let conn
// Why: dispose any existing session to avoid leaking the old multiplexer,
// providers, and timers. This handles double-connect (user clicks connect
// while already connected) and reconnect-after-error.
if (existingSession) {
// Why: await port teardown before disposing so the OS fully releases
// local ports. Without this, restorePortForwards in the new session
// can hit EADDRINUSE on the same ports the old session was using.
await portForwardManager!.removeAllForwards(targetId)
existingSession.detach()
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
clearRelayStateOverride(targetId)
}
// Why: create the session early so onStateChange sees it in 'deploying'
// state and knows not to trigger reconnect logic.
const session = new SshRelaySession(
targetId,
getCurrentMainWindow,
persistedStore!,
portForwardManager!,
currentRuntime,
broadcastDetectedPortsFromCurrentWindow
)
configureRelaySessionCallbacks(session)
activeSessions.set(targetId, session)
try {
conn = await connectionManager!.connect(target)
} catch (err) {
// Why: SshConnection.connect() sets its internal state, but the
// onStateChange callback may not have propagated to the renderer.
// Explicitly broadcast so the UI leaves 'connecting'.
const errObj = err instanceof Error ? err : new Error(String(err))
const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error'
// Why: if a credential prompt was shown before the failure, the target
// would stay in credentialRequestedForTarget. A later successful connect
// that doesn't prompt would then incorrectly persist lastRequiredPassphrase
// = true, causing startup to defer this target even though it no longer
// needs a passphrase.
credentialRequestedForTarget.delete(targetId)
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
clearRelayStateOverride(targetId)
broadcastSshState(getCurrentMainWindow, targetId, {
targetId,
status,
error: errObj.message,
reconnectAttempt: 0
})
throw err
}
try {
// Deploy relay and establish multiplexer
callbacks.onStateChange(targetId, {
targetId,
status: 'deploying-relay',
error: null,
reconnectAttempt: 0
})
await session.establish(conn, relayGracePeriodForTarget(target))
// Why: we manually pushed `deploying-relay` above, so the renderer's
// state is stuck there. Send `connected` directly to the renderer
// instead of going through callbacks.onStateChange, which would
// trigger the reconnection logic.
const win = getCurrentMainWindow()
if (win && !win.isDestroyed()) {
clearRelayStateOverride(targetId)
win.webContents.send('ssh:state-changed', {
targetId,
state: {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0
}
})
}
} catch (err) {
// Relay deployment failed — disconnect SSH
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
await connectionManager!.disconnect(targetId)
throw err
}
// Why: persist whether this connection required a credential prompt so
// startup reconnect can partition targets into eager vs deferred without
// re-probing keys. Updated on every successful connect so the flag stays
// current as users add/remove passphrases from their keys.
const requiredPassphrase = credentialRequestedForTarget.has(targetId)
credentialRequestedForTarget.delete(targetId)
sshStore!.updateTarget(targetId, { lastRequiredPassphrase: requiredPassphrase })
return getPublicSshState(targetId)!
}
ipcMain.handle('ssh:disconnect', async (_event, args: { targetId: string }) => {
const session = activeSessions.get(args.targetId)
if (session) {
// Why: await port teardown so local listeners are fully released
// before the disconnect completes. Without this, an immediate
// reconnect can hit EADDRINUSE on the same ports.
await portForwardManager!.removeAllForwards(args.targetId)
session.detach()
activeSessions.delete(args.targetId)
clearRelayLostBackoff(args.targetId)
clearRelayStateOverride(args.targetId)
}
await connectionManager!.disconnect(args.targetId)
})
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
const session = activeSessions.get(args.targetId)
const provider = getSshPtyProvider(args.targetId)
const leasedIds = persistedStore!
.getSshRemotePtyLeases(args.targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
.map((lease) => lease.ptyId)
const ptyIdsByRelayId = new Map<string, string>()
for (const ptyId of getPtyIdsForConnection(args.targetId)) {
const relayPtyId = toRelaySshPtyId(args.targetId, ptyId)
ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId))
}
for (const ptyId of leasedIds) {
const relayPtyId = toRelaySshPtyId(args.targetId, ptyId)
ptyIdsByRelayId.set(
relayPtyId,
ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId)
)
}
const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({
relayPtyId,
appPtyId
}))
if (ptyIds.length > 0 && !provider) {
throw new Error(
`${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.`
)
}
const shutdownResults = provider
? await Promise.allSettled(
ptyIds.map(({ appPtyId }) =>
provider.shutdown(appPtyId, { immediate: true, keepHistory: false })
)
)
: []
const shutdownFailures: string[] = []
for (const [index, result] of shutdownResults.entries()) {
const { appPtyId, relayPtyId } = ptyIds[index]
if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) {
shutdownFailures.push(
`${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
)
continue
}
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
}
if (shutdownFailures.length > 0) {
// Why: a failed relay shutdown can leave the remote process alive in the
// grace window. Keep the lease/session intact so the user can retry.
throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`)
}
if (session) {
await portForwardManager!.removeAllForwards(args.targetId)
session.dispose()
activeSessions.delete(args.targetId)
clearRelayLostBackoff(args.targetId)
clearRelayStateOverride(args.targetId)
}
await connectionManager!.disconnect(args.targetId)
})
async function doResetRelay(targetId: string, target: SshTarget): Promise<void> {
const inFlightConnect = connectInFlight.get(targetId)
if (inFlightConnect) {
try {
// Why: reset tears down activeSessions; doing that while doConnect is
// still deploying can dispose the session doConnect is about to use.
await inFlightConnect
} catch {
// The reset can still recover a stale remote relay after a failed connect.
}
}
const session = activeSessions.get(targetId)
if (session) {
await portForwardManager!.removeAllForwards(targetId)
// Why: reset has its own stale-relay lease semantics below. dispose()
// records clean PTY termination, which hides reset-affected leases.
session.detach()
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
}
const existingConn = connectionManager!.getConnection(targetId)
const conn = existingConn ?? (await connectionManager!.connect(target))
try {
await forceStopRelayForTarget(conn, targetId)
} finally {
const ptyIds = new Set(getPtyIdsForConnection(targetId))
for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) {
if (lease.state !== 'terminated' && lease.state !== 'expired') {
ptyIds.add(lease.ptyId)
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
}
}
// Why: reset force-kills the remote relay daemon, so every local PTY
// handle owned by that relay is stale even if the reset command failed
// after the remote process accepted SIGTERM.
for (const ptyId of ptyIds) {
const appPtyId = toAppSshPtyId(targetId, ptyId)
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
}
// Why: reset's connect() can trip onCredentialRequest, which adds to
// credentialRequestedForTarget. Without this delete, a later doConnect
// that doesn't prompt would still persist lastRequiredPassphrase=true.
credentialRequestedForTarget.delete(targetId)
await connectionManager!.disconnect(targetId)
}
}
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
const existingReset = resetRelayInFlight.get(args.targetId)
if (existingReset) {
return existingReset
}
const target = sshStore!.getTarget(args.targetId)
if (!target) {
throw new Error(`SSH target "${args.targetId}" not found`)
}
let resetPromise: Promise<void>
resetPromise = Promise.resolve()
.then(() => doResetRelay(args.targetId, target))
.finally(() => {
if (resetRelayInFlight.get(args.targetId) === resetPromise) {
resetRelayInFlight.delete(args.targetId)
}
})
resetRelayInFlight.set(args.targetId, resetPromise)
return resetPromise
})
ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => {
return getPublicSshState(args.targetId)
})
// Why: callers that want to auto-connect (Cmd+J jump, terminal reattach) need
// to know whether doing so will pop a passphrase/password dialog. Auto-firing
// the connect is fine when no prompt is needed, but surprising otherwise —
// the user expects to enter the credential before the app starts connecting.
// Returns true if the target's last successful connect required a credential
// AND the live SshConnection (if any) does not already have one cached.
ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
if (!target?.lastRequiredPassphrase) {
return false
}
const conn = connectionManager!.getConnection(args.targetId)
return !conn?.hasCachedCredential()
})
ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
if (!target) {
throw new Error(`SSH target "${args.targetId}" not found`)
}
// Why: testConnection calls connect() then disconnect(). If the target
// already has an active relay session, connect() would reuse the connection
// but disconnect() would tear down the entire relay stack — killing all
// active PTYs and file watchers for a "test" that was supposed to be safe.
// Also guard 'reconnecting' — disconnect() would kill the SSH connection
// that the in-flight reconnect is using for relay deployment.
const existingSession = activeSessions.get(args.targetId)
const sessionState = existingSession?.getState()
if (
sessionState === 'ready' ||
sessionState === 'deploying' ||
sessionState === 'reconnecting'
) {
return { success: true, state: connectionManager!.getState(args.targetId) }
}
// Why: if a real ssh:connect is in flight for this target, testConnection's
// disconnect() call would tear down the connection that doConnect is using
// for relay deployment. Wait for the in-flight connect to finish instead.
const inFlight = connectInFlight.get(args.targetId)
if (inFlight) {
try {
const state = await inFlight
return { success: true, state }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err)
}
}
}
testingTargets.add(args.targetId)
try {
const conn = await connectionManager!.connect(target)
const state = conn.getState()
await connectionManager!.disconnect(args.targetId)
return { success: true, state }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err)
}
} finally {
testingTargets.delete(args.targetId)
// Why: the shared onCredentialRequest callback adds to this set for
// any connect() call, including testConnection. Without clearing it,
// a later real connect that doesn't prompt would persist
// lastRequiredPassphrase=true, causing startup to defer this target.
credentialRequestedForTarget.delete(args.targetId)
}
})
// ── Port forwarding ─────────────────────────────────────────────────
ipcMain.handle(
'ssh:addPortForward',
async (
_event,
args: {
targetId: string
localPort: number
remoteHost: string
remotePort: number
label?: string
}
) => {
const conn = connectionManager!.getConnection(args.targetId)
if (!conn) {
throw new Error(`SSH connection "${args.targetId}" not found`)
}
const entry = await portForwardManager!.addForward(
args.targetId,
conn,
args.localPort,
args.remoteHost,
args.remotePort,
args.label
)
persistPortForwards(args.targetId)
broadcastPortForwards(getCurrentMainWindow, args.targetId)
return entry
}
)
ipcMain.handle(
'ssh:updatePortForward',
async (
_event,
args: {
id: string
targetId: string
localPort: number
remoteHost: string
remotePort: number
label?: string
}
) => {
const conn = connectionManager!.getConnection(args.targetId)
if (!conn) {
throw new Error(`SSH connection "${args.targetId}" not found`)
}
try {
const entry = await portForwardManager!.updateForward(
args.id,
conn,
args.localPort,
args.remoteHost,
args.remotePort,
args.label
)
persistPortForwards(entry.connectionId)
broadcastPortForwards(getCurrentMainWindow, entry.connectionId)
return entry
} catch (err) {
// Why: if the edit failed (and rollback may also have failed),
// sync the renderer with the actual runtime state so it doesn't
// show a forward that no longer exists.
persistPortForwards(args.targetId)
broadcastPortForwards(getCurrentMainWindow, args.targetId)
throw err
}
}
)
ipcMain.handle('ssh:removePortForward', (_event, args: { id: string }) => {
const removed = portForwardManager!.removeForward(args.id)
if (removed) {
persistPortForwards(removed.connectionId)
broadcastPortForwards(getCurrentMainWindow, removed.connectionId)
}
return removed
})
ipcMain.handle('ssh:listPortForwards', (_event, args?: { targetId?: string }) => {
const all = portForwardManager!.listForwards(args?.targetId)
if (!persistedStore || !args?.targetId) {
// Why: the cross-target list is rare and we cannot map every entry to
// worktrees in a single call; serve the raw list. Per-target callers
// get full enrichment.
return all
}
return enrichSshForwardEntries(all, getWorktreeIdsForConnection(persistedStore, args.targetId))
})
ipcMain.handle('ssh:listDetectedPorts', (_event, args: { targetId: string }) => {
const session = activeSessions.get(args.targetId)
const ports = session?.getPortScanner()?.getDetectedPorts(args.targetId) ?? []
return enrichDetected(args.targetId, ports)
})
return { connectionManager, sshStore }
}
export function getSshConnectionManager(): SshConnectionManager | null {
return connectionManager
}
export async function resetSshHandlerStateForTests(): Promise<void> {
advertisedUrlWatcherUnsubscribe?.()
advertisedUrlWatcherUnsubscribe = null
powerMonitorUnsubscribe?.()
powerMonitorUnsubscribe = null
for (const ch of SSH_IPC_CHANNELS) {
ipcMain.removeHandler(ch)
}
ipcMain.removeHandler('ssh:submitCredential')
for (const session of activeSessions.values()) {
session.dispose()
}
activeSessions.clear()
for (const targetId of relayLostBackoff.keys()) {
clearRelayLostBackoff(targetId)
}
relayStateOverrides.clear()
connectInFlight.clear()
resetRelayInFlight.clear()
testingTargets.clear()
credentialRequestedForTarget.clear()
await connectionManager?.disconnectAll()
portForwardManager?.dispose()
connectionManager = null
portForwardManager = null
sshStore = null
persistedStore = null
registeredConnectSshTarget = null
registeredGetSshState = null
currentGetMainWindow = () => null
currentRuntime = undefined
}
export function getSshConnectionStore(): SshConnectionStore | null {
return sshStore
}
export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined {
return activeSessions.get(connectionId)?.getMux() ?? undefined
}