# SSH remote sessions

> SSHConfig fields, connection test, remote workdir setup scripts, resume candidates, download/teleport session flows, and detached bridge recovery.

- Repository: Parcha-ai/build
- GitHub: https://github.com/Parcha-ai/build
- Human docs: https://grok-wiki.com/public/docs/parcha-ai-build-bea5702b371b
- Complete Markdown: https://grok-wiki.com/public/docs/parcha-ai-build-bea5702b371b/llms-full.txt

## Source Files

- `src/shared/types/index.ts`
- `src/main/services/ssh.service.ts`
- `src/main/ipc/ssh.ipc.ts`
- `src/renderer/components/session/SSHConfigForm.tsx`
- `src/renderer/components/session/DownloadSessionDialog.tsx`
- `scripts/verify-ssh-detached-bridge-resume.js`
- `.notes/ssh-download-implementation-summary.md`

---

---
title: "SSH remote sessions"
description: "SSHConfig fields, connection test, remote workdir setup scripts, resume candidates, download/teleport session flows, and detached bridge recovery."
---

Build runs agent harnesses on a remote host over SSH: the main process owns `ssh.service.ts` and `ssh.ipc.ts`, the renderer collects connection settings in `SSHConfigForm`, and each `Session` with `sshConfig` stores the remote workdir, SDK transcript mapping, and optional teleport/download lineage (`teleportedFrom`, `downloadedFrom`).

## Architecture

```mermaid
flowchart TB
  subgraph renderer["Renderer"]
    SSHConfigForm["SSHConfigForm"]
    DownloadDialog["DownloadSessionDialog"]
    SessionStore["session.store"]
  end
  subgraph preload["Preload bridge"]
    electronAPI_ssh["window.electronAPI.ssh"]
  end
  subgraph main["Main process"]
    ssh_ipc["ssh.ipc.ts"]
    ssh_svc["ssh.service.ts"]
    claude_svc["claude.service.ts"]
    session_store["claudette-sessions"]
  end
  subgraph remote["Remote host"]
    harness_cli["Harness CLIs"]
    claude_projects["~/.claude/projects/…"]
    detached_bridge["/tmp/claudette-ssh-bridge/{sessionId}/"]
  end
  SSHConfigForm --> electronAPI_ssh
  DownloadDialog --> electronAPI_ssh
  SessionStore --> electronAPI_ssh
  electronAPI_ssh --> ssh_ipc
  ssh_ipc --> ssh_svc
  ssh_ipc --> session_store
  claude_svc --> ssh_svc
  ssh_svc --> harness_cli
  ssh_svc --> claude_projects
  ssh_svc --> detached_bridge
```

| Layer | Responsibility |
| --- | --- |
| `SSHConfigForm` | Connection test, saved config, resume picker, teleport mode |
| `ssh.ipc.ts` | Session create, teleport, download, reconnect, progress events |
| `ssh.service.ts` | ssh2 client pool, setup scripts, SFTP, detached bridge spawn/attach |
| `claude.service.ts` | `createRemoteProcess` hook and `resumeRemoteTurn` for recovery |
| `claudette-settings` | `lastSSHConfig` (no passphrase) |
| `claudette-sessions` | `sessions.*`, `sdkSessionMappings.*` |

## SSHConfig and saved settings

Runtime sessions use `SSHConfig` on `Session.sshConfig`. The form persists a passphrase-free `SavedSSHConfig` under `claudette-settings` key `lastSSHConfig`.

<ParamField body="host" type="string" required>
Remote hostname or IP.
</ParamField>

<ParamField body="port" type="number">
SSH port. Default `22` when omitted at connect time.
</ParamField>

<ParamField body="username" type="string" required>
SSH user.
</ParamField>

<ParamField body="privateKeyPath" type="string" required>
Local path to the private key file (read on the main process).
</ParamField>

<ParamField body="remoteWorkdir" type="string" required>
Directory where harness tools run (`repoPath` / `worktreePath` on the session). May be rewritten after a setup script.
</ParamField>

<ParamField body="passphrase" type="string">
Optional key passphrase. Used only in memory for the connection; never stored in `lastSSHConfig`.
</ParamField>

<ParamField body="worktreeScript" type="string">
Shell command run from `$HOME` before the session starts (see setup scripts). Disables resume-candidate UI when non-empty.
</ParamField>

<ParamField body="syncSettings" type="boolean">
When not `false` (default **true**), syncs local `~/.claude` artifacts to the remote during pre-session setup.
</ParamField>

`SSHResumeCandidate` (from `ssh:list-resume-candidates`) includes `sessionId`, `backend: 'claude'`, `mtime`, and optional `localSessionId` / `localSessionName` when a local Build session already maps the same SDK id.

## Connection test

<Steps>
<Step title="Collect connection + workdir">
Fill host, username, private key, and remote working directory. `SSHConfigForm` debounces an automatic test (~800ms) when all required fields are present.
</Step>
<Step title="Invoke test">
Renderer calls `window.electronAPI.ssh.testConnection(config)` → `SSH_TEST_CONNECTION` → `sshService.testConnection`.
</Step>
<Step title="Verify remote harnesses">
On success, the service detects `claude`, `codex`, `cursor-agent`, `gemini`, and `opencode` on the remote PATH. At least one must exist or the test fails with `missingCliInstallCommands` install hints.
</Step>
<Step title="Optional resume probe">
After a successful test (non-teleport, no worktree script), the form calls `listResumeCandidates` to populate the existing-session picker.
</Step>
</Steps>

<ResponseField name="SSHConnectionTestResult" type="object">
<ParamField body="success" type="boolean" />
<ParamField body="error" type="string" />
<ParamField body="hostname" type="string" />
<ParamField body="claudeCodeVersion" type="string" />
<ParamField body="cliCapabilities" type="RemoteCliCapabilities" />
<ParamField body="setupWarning" type="string" />
<ParamField body="missingCliInstallCommands" type="RemoteCliSetupCommand[]" />
</ResponseField>

Connection uses a **30 second** ready/timeout window. Common errors are normalized (authentication, `ECONNREFUSED`, `ENOTFOUND`, `ETIMEDOUT`). `SSH_CHECK_CONNECTION` is a thin wrapper that returns `{ connected, error }` from the same test.

## Create session and pre-session setup

`SSH_CREATE_SESSION` returns a `Session` immediately with `status: 'creating'` while setup runs asynchronously. Progress is pushed on `SSH_SETUP_PROGRESS`; status updates use `SESSION_STATUS_CHANGED`.

```mermaid
sequenceDiagram
  participant UI as SSHConfigForm
  participant IPC as ssh.ipc
  participant SVC as ssh.service
  participant Remote as Remote host
  UI->>IPC: createSession(config, resumeSessionId?)
  IPC->>IPC: persist session + sdkSessionMappings
  IPC-->>UI: Session (creating)
  IPC->>SVC: connect(sessionId)
  alt worktreeScript or syncSettings
    IPC->>SVC: runPreSessionSetup
    SVC->>Remote: worktree script / SFTP sync
    SVC-->>IPC: workingDirectory?
  end
  IPC->>UI: SSH_SETUP_PROGRESS + status running/error
```

### Worktree setup script

When `worktreeScript` is set, `runWorktreeScript` runs:

```bash
source ~/.bash_profile 2>/dev/null; source ~/.profile 2>/dev/null; source ~/.bashrc 2>/dev/null; true
cd ~ && <worktreeScript> && echo "___WORKDIR_END___"
```

**Convention:** the last non-empty line of stdout **before** the marker is treated as the new `remoteWorkdir` / `worktreePath`. Setup output is stored on `session.setupOutput`. Resume selection is disabled in the UI because the final directory is unknown until the script finishes.

### Settings sync

When `syncSettings !== false`, `syncSettings` uploads (via SFTP/rsync) local `~/.claude/agents`, `commands`, `CLAUDE.md`, `settings.json`, skills, MCP config/auth, and optional GitHub `gh` hosts metadata. Sync failure logs a warning but does not fail the whole setup.

### SDK session mapping

| Case | `sdkSessionMappings[sessionId]` |
| --- | --- |
| Resume existing remote transcript | Remote SDK `sessionId` from picker |
| New session | Sentinel `'new'` until first agent turn assigns a real id |
| Local match on resume | Also sets `continuedFromSessionId` when host/user/workdir + mapping align |

Default session name: `{host}:{last-two-path-segments-of-workdir}`.

## Resume candidates

`SSH_LIST_RESUME_CANDIDATES` opens a short-lived probe connection (`ssh-resume-probe-{uuid}`), lists `~/.claude/projects/{escaped-remoteWorkdir}/*.jsonl` (excluding `agent-*`), sorts by mtime, returns the **top 10**, and disconnects the probe.

Transcript path escaping: `/` in the workdir becomes `-` (e.g. `/home/user/repo` → `-home-user-repo` under `~/.claude/projects/`).

## Teleport (local → remote)

Teleport moves a **local** session to SSH by copying transcripts after the final remote workdir is known.

<Steps>
<Step title="Open TeleportDialog">
Uses `SSHConfigForm` in teleport mode (`teleportSource` session, `onTeleport` handler).
</Step>
<Step title="Pre-session setup first">
`SSH_TELEPORT_SESSION` connects, runs `runPreSessionSetup` when `worktreeScript` or default sync applies, and captures `finalWorkdir`.
</Step>
<Step title="Copy transcripts">
`sshService.teleportSession` uploads `{sdkSessionId}.jsonl` from local `~/.claude/projects/-{escaped-local-worktree}/` into remote `~/.claude/projects/-{escaped-final-workdir}/` (SFTP uses absolute paths).
</Step>
<Step title="Create remote session record">
New session: `sshConfig`, `sdkSessionId` preserved, `teleportedFrom: sourceSessionId`, `status: 'stopped'`. Source must not already have `sshConfig`.
</Step>
</Steps>

<Warning>
Cannot teleport a session that is already SSH-backed (`sshConfig` present).
</Warning>

## Download (remote → local)

Reverse teleport: `DownloadSessionDialog` calls `downloadSession` with `DownloadSessionConfig`.

<ParamField body="localRepoPath" type="string" required>
Existing local git repository root (user picks via `dev.openLocalRepo`).
</ParamField>

<ParamField body="sessionName" type="string" required>
Name for the new local session.
</ParamField>

<ParamField body="branch" type="string">
Optional branch; defaults to remote `git branch --show-current` when omitted.
</ParamField>

Progress strings are broadcast on `SSH_DOWNLOAD_PROGRESS`.

| Step | Action |
| --- | --- |
| 1 | Require source `sshConfig`; read `sdkSessionId` from mapping or session |
| 2 | Connect (`download-{timestamp}`), read `git remote get-url origin`, branch, transcript path |
| 3 | Validate local repo exists; warn on origin URL mismatch but continue |
| 4 | Create worktree under `~/.claudette/worktrees/{repo-hash}/dl-{uuid}/` |
| 5 | SFTP transcript to `~/.claude/projects/{escaped-worktree}/{sdkSessionId}.jsonl` (non-fatal if missing) |
| 6 | New local session: `downloadedFrom`, `isWorktree: true`, `sdkSessionId` preserved, `status: 'stopped'` |

## Detached bridge and recovery

Remote harness turns do not use bare `ssh exec` for Claude. `createRemoteProcess` launches a **detached bridge** on the remote host so agent work survives app quit, sleep, and transport drops (`app.on('will-quit')` intentionally does not kill remote jobs).

```text
/tmp/claudette-ssh-bridge/{safeSessionId}/
  └── {jobId}/
        config.json
        stdout.log      # append-only stream log
        exit.json
        stdin.sock
        pid
        recovered.json  # after successful reattach
```

The bridge script is installed from `remote-bridge-script.ts` (`REMOTE_DETACHED_BRIDGE_SCRIPT`). Regression coverage: `node scripts/verify-ssh-detached-bridge-resume.js`.

### Recoverable job criteria

`getLatestRecoverableRemoteProcess` returns a job when:

- Not marked `recovered`
- Command is empty or `claude`
- **Active** (`kill -0` on pid), **or**
- **Completed** within the last **24 hours**, with `metadata.json`, `logBytes > 0`

`SSH_HAS_RECOVERABLE_REMOTE_PROCESS` / `SSH_HAS_ACTIVE_REMOTE_PROCESS` expose this to the renderer.

### Reattach flow

When you focus an SSH session, `startRemoteProcessMonitor` in `session.store.ts`:

1. Checks `hasRecoverableRemoteProcess` (fallback: `hasActiveRemoteProcess`)
2. Sets streaming UI state if recoverable
3. Loads transcript if messages are empty
4. If no backend query is active, calls `claude.resumeRemoteTurn` → `attachLatestDetachedCommandProcess` → tails `stdout.log` and replays JSONL stream events
5. Polls until the remote process finishes, then reloads messages (with delay so `onStreamEnd` can flush)

`SSH_CONNECTION_LOST` clears stale permission/question dialogs but **preserves** in-flight stream state; the backend reconnects and continues tailing the same log.

`SSH_RECONNECT` disconnects, waits 500ms, and calls `connect` again (invalidates transcript cache). Use from session card when the transport is dead but you want a fresh SSH session object.

Explicit cancel/delete still runs `killRemoteProcesses` (bridge job dirs and legacy tmux names).

## IPC surface (renderer)

| Channel | Pattern | Purpose |
| --- | --- | --- |
| `ssh:test-connection` | invoke | Full harness probe |
| `ssh:check-connection` | invoke | `{ connected }` shorthand |
| `ssh:create-session` | invoke | Create + async setup |
| `ssh:list-resume-candidates` | invoke | Remote transcript list |
| `ssh:get-saved-config` / `ssh:save-config` | invoke | `lastSSHConfig` |
| `ssh:sync-settings` | invoke | Manual settings push |
| `ssh:run-worktree-script` | invoke | Ad-hoc script run |
| `ssh:teleport-session` | invoke | Local → remote |
| `ssh:download-session` | invoke | Remote → local |
| `ssh:reconnect` | invoke | Transport reset |
| `ssh:browse-remote-files` | invoke | Directory listing for pickers |
| `ssh:has-active-remote-process` | invoke | Active bridge/tmux check |
| `ssh:has-recoverable-remote-process` | invoke | Reattach eligibility |
| `ssh:setup-progress` | event | Setup log + status |
| `ssh:download-progress` | event | Download step text |
| `ssh:connection-lost` | event | Transport dropped |

Preload namespace: `window.electronAPI.ssh` in `preload.ts`.

## Session fields (SSH-specific)

| Field | Meaning |
| --- | --- |
| `sshConfig` | Marks session as remote |
| `isDevMode` | `true` for SSH (no Docker) |
| `setupOutput` | Captured setup script stdout |
| `teleportedFrom` | Source local session id after teleport |
| `downloadedFrom` | Source SSH session id after download |
| `sdkSessionId` / `sdkSessionMappings` | Claude Agent SDK transcript id |
| `continuedFromSessionId` | Local session linked on SSH resume |

## Troubleshooting

<AccordionGroup>
<Accordion title="Connection test fails with no harness CLI">
Install at least one supported CLI on the remote host. The test result includes per-harness `npm`/`curl` install commands when capabilities are missing.
</Accordion>
<Accordion title="Setup script succeeded but wrong workdir">
Ensure the script prints the absolute workdir as its **last** stdout line before the internal `___WORKDIR_END___` marker.
</Accordion>
<Accordion title="Resume list empty">
Confirm transcripts exist under `~/.claude/projects/{escaped-remoteWorkdir}/` on the remote host and that you are not using a worktree script (resume is disabled until workdir is stable).
</Accordion>
<Accordion title="Stream stuck after laptop sleep">
Detached bridge should keep the remote turn alive. Focus the session to trigger recovery, or use reconnect on the session card. Check `/tmp/claudette-ssh-bridge/{sessionId}/` on the remote host for active jobs.
</Accordion>
<Accordion title="Download warns on git remote mismatch">
Download proceeds when local `origin` differs from remote; align remotes manually if you need strict parity.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Sessions and workspaces" href="/sessions-and-workspaces">
Session schema, statuses, worktrees, and `claudette-sessions` persistence.
</Card>
<Card title="IPC and preload bridge" href="/ipc-bridge">
How `electronAPI` maps to `ipcMain` handlers.
</Card>
<Card title="IPC channels reference" href="/ipc-channels-reference">
Full `SSH_*` channel catalog.
</Card>
<Card title="Manage coding sessions" href="/manage-sessions">
Teleport/import UX, session switcher, and lifecycle actions.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
SSH bridge recovery, logs, and common failures.
</Card>
</CardGroup>
