Remote Runtime Internal Architecture
August 26, 2026 · View on GitHub
Remote runtime support is built on the same JSON-RPC runtime the local ADE runtime answers. The desktop chooses a runtime binding for each window; the renderer APIs stay stable while preload decides whether to call the local machine runtime or a remote SSH-backed runtime. Both bindings speak the same wire protocol.
Runtime bindings
OpenProjectBinding records the active runtime for a window:
kind: "local"— actions go throughLocalRuntimeConnectionPool, which connects to the machine socket (~/.ade/sock/ade.sock) and spawnsade serveif it is not running.kind: "remote"— actions go throughRemoteConnectionPoolkeyed by{ targetId, projectId }.
The binding is established when a project is opened. Local bindings are created from the current desktop project (the desktop calls LocalRuntimeConnectionPool.ensureProject(rootPath) to register the project with the daemon and capture its projectId). Remote bindings are created by remoteRuntimeOpenProject after the selected target is connected and the remote project record is confirmed. Remote opens are generation-guarded twice: preload only remembers the newest ade.remoteRuntime.openProject result, and runtimeBridge.ts only calls bindRemoteProject for the newest in-flight open request from that window/webContents. A slow earlier open can still return its binding to its caller, but it cannot overwrite the window session or lastRemoteProjectBinding.
Protocol shape
Runtime-level methods do not require a project and operate on the daemon as a whole:
ade/initialize ade/initialized ping shutdown exit
runtime/info machineInfo.get
projects.list projects.add projects.remove projects.touch
runtimeEvents.subscribe runtimeEvents.unsubscribe
sync.getStatus sync.refreshDiscovery
sync.listDevices sync.updateLocalDevice
sync.connectToBrain sync.disconnectFromBrain
sync.forgetDevice
sync.getTransferReadiness sync.transferBrainToLocal
sync.getPin sync.setPin sync.clearPin
sync.setActiveLanePresence
The connectToBrain, disconnectFromBrain, and transferBrainToLocal
method names are legacy wire identifiers. New documentation should use
runtime connection, runtime disconnection, and sync authority transfer.
Project-scoped operations are routed through ade/actions/call and carry params.projectId. The ade-cli multi-project RPC handler (createMultiProjectRpcRequestHandler) looks up the per-project service scope via ProjectScopeRegistry.get(projectId) and forwards the request to the cached single-project handler created from createAdeRpcRequestHandler({ runtime, … }).
ade/initialize advertises runtimeInfo.multiProject: true, runtimeInfo.packageChannel (when set on the daemon environment), and capabilities.projects: true. Clients use the multi-project flag to decide whether to send projectId per request (multi-project runtime) or treat the runtime as already bound to one project (embedded ade code --embedded). validateRemoteRuntimeInitializeResult enforces both top-level capabilities, normalizes the per-method capabilities.machineProjects flags (browseDirectories, getDetail, getWorkSummary, getDefaultParentDir, create, clone, listMyGitHubRepos), and turns version mismatch / channel mismatch / missing capabilities into a RemoteRuntimeInitializeInfo.compatibilityWarnings array instead of throwing. Those warnings flow back to bootstrapRemoteRuntime, are returned on RemoteRuntimeConnectResult.compatibilityWarnings, and are surfaced inline under the remote target's connection chip.
RemoteRuntimeCapabilities (in apps/desktop/src/shared/types/remoteRuntime.ts) is the structured shape both the connect result and the connection status carry. RemoteConnectionPool.assertMachineProjectCapability maps projects.* RPC method names to the matching capability flag and rejects the call with a self-describing message before it leaves the desktop when the remote did not advertise that capability — the connection stays open for everything else.
Runtime event streaming uses ade/actions/call with name: "stream_events" for one-shot pulls, and runtimeEvents.subscribe (with runtime/event notifications) for live streaming. For remote bindings the desktop reconnects the SSH transport before re-subscribing, matching normal remote action behavior after disconnects. The initial remote subscription starts with replay: false when the cursor is still zero, so opening a remote project does not flood the renderer with buffered history before live events arrive; catch-up polls still use a short delay while idle remote polls back off. For local bindings, preload polls the local runtime through localRuntimeStreamEvents so runtime-owned chat, terminal, pty, lane, file-watch, process, and test events are delivered through the same renderer fanout used by remote projects.
A window runs one pump for its active binding (preload.ts) plus, for every binding it has open but is not bound to, the pinned pumps in preload/pinnedRuntimeEvents.ts — one shared PTY pump per pinned binding and one generic pump per pinned chat/project listener. Main keys its subscriptions by (sender, requestKey) so those pumps coexist instead of evicting each other (see ARCHITECTURE §5.4), and preload sends ade.runtime.events.release when the last pump on a (binding, category) stops reading, so a switched-away binding stops streaming immediately rather than at idle expiry.
Each stream_events response carries a per-runtime eventEpoch UUID minted when the daemon's eventBuffer is constructed. The pump compares it against the last seen epoch for its binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from cursor=0. Responses can also include gap: true with oldestCursor when the requested cursor is older than the bounded replay buffer; preload clears the dedupe set and notifies the project-binding refresh callbacks so renderer projections re-hydrate from authoritative reads instead of assuming no events were missed. The startedAtMs "drop events older than the pump start" filter is only applied to local bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered.
The remote event buffer categories are intentionally narrow: orchestrator, dag_mutation, runtime, and pty. Preload dispatches runtime events by their payload type so domain-specific updates such as agent chat, terminal, lane, PR, file-watch, process, test, project-state, usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, and App Control changes still reach their dedicated remote subscribers without expanding the wire-level category enum. ade-cli wires these source-tagged payloads into the runtime event buffer in bootstrap.ts so a remote-bound window sees the same event fanout as the local host. Headless runtimes start usageTrackingService during createAdeRuntime() after the ADE action registry is bound, so the usage poller and threshold events run only once the runtime can answer the matching usage/budget actions.
SSH transport
sshTransport.ts creates an ssh2 client config from the saved target:
- host, port, and username come from the remote target registry.
sshKeyPathloads a private key from disk when supplied.- if no explicit key path is saved, matching
HostNameandIdentityFileentries in~/.ssh/configare applied so aliases likeHost studiowork. SSH_AUTH_SOCKis passed through asagentwhen available.- every config carries a bounded
readyTimeout(ADE_REMOTE_SSH_CONNECT_TIMEOUT_MS, default 10 s) and disables SSH-level keepalives; runtime RPC calls, exec probes, and artifact uploads carry their own timeouts so large channel writes are not interrupted by transport probes.
The runtime transport itself is an SSH exec channel running ade rpc --stdio (with the channel-aware environment prefix from buildRemoteRuntimeEnvironmentPrefix). The channel implements the RuntimeRpcTransport interface used by RuntimeRpcClient, the same client LocalRuntimeConnectionPool uses against a Unix socket.
Short SSH exec probes use ADE_REMOTE_SSH_EXEC_TIMEOUT_MS (default 30 s). Connect failures are normalized before surfacing to the renderer so handshake timeouts, connection resets before ready, and local TCP port exhaustion have actionable messages.
ade code remote uses the system OpenSSH client rather than the desktop's
ssh2 transport, but preserves the same config identity. The destination stays
the saved target hostname (and therefore remains the Host pattern selector in
~/.ssh/config); alternate LAN/tailnet addresses are supplied with
-o HostName=<route>. This keeps alias-scoped User, IdentityFile, agent,
ProxyJump/ProxyCommand, and other OpenSSH settings active while dialing a
concrete address. The CLI also passes StrictHostKeyChecking=yes; route fallback
never relaxes host-key verification.
Multi-route fallback
A remote target stores a primary hostname plus an optional routes array (RemoteRuntimeTargetRoute[]). Each route has { hostname, port, source: "manual" | "bonjour" | "tailscale", lastSucceededAt }. Discovery captures every reachable address advertised by a peer (Tailscale FQDN, mDNS host, raw IPv4/IPv6 entries) and RemoteTargetForm persists them alongside the manual host on save.
buildSshRouteCandidates flattens routes (deduped by hostname:port, primary always first) and sorts later attempts by most-recent lastSucceededAt. connectSshWithRoute walks the route × username matrix: an authentication failure tries the next username for the same route, any other failure skips remaining usernames and advances to the next route. On success, bootstrapRemoteRuntime calls markRemoteTargetRouteSucceeded so the registry remembers which route worked — a target that moved between LAN and Tailscale auto-prefers whichever one reconnected last.
Discovery diagnostics
discoverLanRuntimes runs Bonjour and tailscale status --json in parallel and now returns a RemoteRuntimeDiscoveryResult with { machines, diagnostics }. Each diagnostic carries { source: "bonjour" | "tailscale", severity, code, message, detail }. Codes today: bonjour-discovery-failed, tailscale-unavailable (CLI not installed), tailscale-timeout, tailscale-status-failed. severity is "warning" when discovery is degraded in a way the user may want to look at, and "info" for a normal, non-actionable observation about the environment. tailscale-unavailable is info — Tailscale is optional software, so not having it installed is a fact about the machine rather than a problem with discovery — while the timeout and failure codes stay warning. The form surfaces warnings inline so a hung or broken Tailscale CLI does not look like "no machines found" (the LAN side still ran), and renders info diagnostics as muted secondary text with no warning glyph.
Bootstrap sequence
bootstrapRemoteRuntime performs first-connect setup:
- Connect over SSH.
- Detect platform and architecture with
uname -sm(normalizeRemoteArchaccepts darwin/linux × arm64/x64). - Read the preferred channel home's
bin/ade.version,bin/ade.sha256, andbin/ade --versionwhen present. - Locate the bundled
ade-<platform-arch>binary,ade-<platform-arch>.native.tar.gzarchive, packagedptyHostWorker.cjs, and bundledagent-skills/root in desktop resources. - If the desktop has no bundled binary for that arch and no executable was found in the preferred home, probe the alternate channel homes (
~/.ade,~/.ade-alpha,~/.ade-beta) for a workingade --version. The first home that responds is adopted as the active layout and the reason is captured for the connection'scompatibilityWarnings(Using remote runtime home <home> because <preferred> did not contain an ADE service for <arch>). - If the local bundle is present and the selected installed version or SHA does not match the desktop bundle, upload the binary to
<layout>/bin/ade(mode 700 dir, +x file, write<layout>/bin/ade.versionand<layout>/bin/ade.sha256). Uploads prefer SFTP; when that cannot start safely, ADE writes bounded SSH chunks and can fall back to OpenSSH for chunks that did not enter the existing channel. - If the native deps archive is present and either the runtime was just uploaded or the remote
<layout>/runtime/<arch>/.ade-versiondoesn't match, upload and extract it to<layout>/runtime/<platform-arch>/. - If the PTY host worker is available locally and the remote worker hash is missing or stale, upload it to
<layout>/runtime/ptyHostWorker.cjswith a sidecar.sha256. When the remote hasnode, the runtime environment pointsADE_PTY_HOST_WORKER_PATHandADE_PTY_HOST_WORKER_NODEat that worker; otherwise it pointsADE_PTY_HOST_WORKER_COMMANDat the uploadedadebinary so the static runtime can run the internal worker entry. - Verify the uploaded runtime by running
<layout>/bin/ade --versionwith the channel/arch/worker environment prefix; abort withUploaded ADE service version mismatchif the reported version doesn't match. - If bundled ADE agent skills are available locally, hash the directory and upload it to
<layout>/agent-skillswhen<layout>/agent-skills.sha256is missing or stale. The remote CLI resolves that root from its own binary path and re-seeds ADE-managed skills into runtime-native home skill directories on launch. - Start
ade rpc --stdio, initialize the JSON-RPC client, normalize capabilities and version throughvalidateRemoteRuntimeInitializeResult, and readprojects.list. Version skew, channel skew, and missing capabilities becomecompatibilityWarningsrather than throws. - If the preferred runtime fails validated initialize, walk the alternate channel homes again with
ade rpc --stdioagainst each candidate even when this connection just uploaded a packaged Beta binary. The first home that completes initialize and capability negotiation wins; failed candidates are collected with their launch, initialization, or capability-negotiation phase so the final error remains actionable. If a fallback wins, the chosen home and environment prefix are retained for every follow-up command and recorded as a compatibility warning. - After SSH RPC and
projects.listsucceed, send a versioned device identity and DPoP public key to that selected runtime withade sync pair-device --json-stdin. The request is bounded and never placed in argv. A valid response stores the paired secret and local DPoP private key, then upgrades the saved target to paired-first while retaining SSH as recovery. This step is best-effort for older compatible runtimes. - Update the target registry with architecture, runtime version (preferring the value the daemon reported through
initialize), last-connected timestamp, explicitautoConnect: true, and a refreshedroutesarray marking the successful route'slastSucceededAt.
If no bundled runtime exists locally and no channel home on the remote can start a compatible RPC, bootstrap fails with an explicit install/build error rather than silently shipping the wrong version.
Channel layout: resolveRemoteRuntimeLayout reads ADE_PACKAGE_CHANNEL for the preferred home; resolveRemoteRuntimeLayoutCandidates enumerates that preferred home plus the stable / alpha / beta layouts (deduped by homeDirName) for the fallback walk. Stable uploads to ~/.ade/; alpha to ~/.ade-alpha/; beta to ~/.ade-beta/. Runtime binaries, native deps, PTY worker artifacts, and bundled ADE agent skills all stay inside the selected home. Channel builds also pass ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 in the environment prefix so the channel binary doesn't fight a stable login service for the socket.
One-time machine trust reset
On the first packaged launch carrying this migration,
machineTrustResetMigration.ts removes only the channel-local saved target and
pairing grant files: remote-machines.json, desktop-paired-machines.json,
sync-paired-devices.json, and
sync-paired-devices.json.runtime-host-grants. It deliberately preserves the
account credential store, stable machine/device identity, sync PIN, bootstrap
token, project registry/state, and SSH files. The migration first writes
.machine-trust-reset-v1.pending; main.ts forces the background runtime
service to restart and atomically renames that marker to
.machine-trust-reset-v1 only after installation/restart reports success. If
ADE exits before confirmation, the next launch retries the restart without
clearing any new pairings a second time. Source/dev launches never run it.
SSH bootstrap and direct LAN/tailnet paired reconnect do not read account
status or an account token. Before a relay candidate is opened, the desktop
requests a current Clerk access token in memory and adds it only to that paired
hello. The host verifies the token subject against its currently signed-in
owner; missing, expired, or different-user proof returns
relay_account_required. The token is never written to the remote target or
paired credential store. A changed SSH host key must pass the explicit trust
flow again. A paired
hello_ok whose authoritative host device identity differs from the stored
identity fails closed and requires re-pairing rather than rewriting the saved
identity.
Cross-machine divergence
Opening a project on another machine is not gated by a confirmation. The git-origin comparison that used to power one now feeds tab grouping (projectTabGrouping.ts), and the risk it was really guarding against — two machines pushing the same branch from different commits — is caught at push time by shared/laneDivergence.ts.
detectPushDivergence runs at click time on the push button, from lane state the renderer already holds (LaneSummary.branchRef + LaneStatus.ahead/behind, unioned across machines by renderer/state/crossMachineLanes.ts). No lane record in ADE carries a head sha, so the rule is grounded in ahead instead: another machine holding the same branch with unpushed commits would have them stranded when the upstream tip moves. Head shas are used only to silence the guard when two machines are proven to sit on the same commit — an unknown head never suppresses a warning, because the false-negative direction on a destructive push is the expensive one. Machine identity is compared by id (shared/machineIdentity.ts), never by name, so the guard cannot mistake This computer for another machine.
Per-session runtime routing
A lane owns its machine; a session — chat, CLI, or shell — inherits its machine from its lane through laneId. Because the Work sidebar is a union across every open machine, the user can click a row whose lane lives on a machine this window's project tab is not bound to. renderer/lib/chatMachineRouting.ts derives the OpenProjectBinding that row's calls must target and returns null when it already lives on the active binding. Two routers are built on it from the same shared constructors: AgentChatPane for chats, and components/terminals/useWorkMachineRouter.ts for the Work tab's CLI/shell rows, which falls back to the remembered launch pin when a row's lane is not in the index.
Within a chat pane that pin is resolved once and provided, not re-derived per panel. components/chat/ChatRuntimeScope.tsx is the canonical derivation: AgentChatPane runs useChatScopeDerivation — session → lane (through useForeignSessionLaneId when the chat is not in this tab's own session list) → pin — and wraps its panel/drawer subtree in ChatRuntimeScopeProvider. Every chat-scoped tool then reads useChatRuntimeScope() for { pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online } instead of asking a global store, which would answer with the tab's machine. pin === null means, and only means, "this chat lives on the tab's binding". Surfaces reused outside a pane — the CLI session header renders ChatGitToolbar with its own pin and no provider above it — take the same derivation from a prop through useChatRuntimeScopeForPin(pin, laneId, bindingOverride?). Lane rows and worktree paths are resolved with useLanesForPin from renderer/state/crossMachineLanes.ts, never against the tab's lanes array: lane ids are unique per machine, not globally, so the tab-bound list can match a different lane sharing the id and hand its worktree path to a tool about to drive another machine.
Preload consumes that as an optional trailing pin on chat/session APIs and on the PTY/terminal surface (callPinnedOrBoundRuntimeActionOr): with a pin the call goes to the pinned runtime through callPinnedRuntimeAction; without one it takes the unchanged bound path and its IPC fallback. Foreign PTY output arrives through the pinned PTY pump described above, and TerminalView keeps the pin on the cached runtime (it is part of the runtime cache key) so two parked terminals from different machines cannot borrow the same route.
The same trailing pin covers the domains a chat-scoped tool drives — the iOS simulator, App Control, and computer-use artifact actions, routed through the domain-bound wrappers callIosSimulatorActionOr / callAppControlActionOr / callComputerUseArtifactActionOr so the domain string is not retyped at every call site — plus the cross-machine handoff trio agentChat.prepareCrossMachineHandoff / validateCrossMachineSource / markCrossMachineHandoff. iosSimulator.onEvent and appControl.onEvent take the pin too, so a pinned panel's live updates follow its reads rather than describing the bound machine's simulator. builtInBrowser.onEvent is the deliberate exception: the built-in browser is hosted by this desktop's main process (it owns a WebContentsView) and the daemon only proxies calls into it over the desktop bridge socket, so a pin on another local checkout still drives this machine's browser and keeps the local IPC stream; only a kind: "remote" pin switches to the pinned runtime stream. Preload's short read caches are namespaced by binding (boundReadCacheKey()) because one preload process serves every machine a window talks to, and a pinned iosSimulator.getStatus / listDevices bypasses the cache entirely. The exposed object is declared satisfies Window["ade"] before contextBridge.exposeInMainWorld, so a signature that drops a pin parameter fails to compile instead of silently talking to the wrong machine.
The Work tools pane (Terminal / Git / Files / iOS / App Control / Browser) honors the pin the same way, through a runtimePin prop taken from the active Work session's router entry.
The tab's binding is never rewritten by opening a session — rebinding would move Lanes, PRs, Files, Git, and Run with it. The one exception is a row whose owning binding this window does not have open: there is nothing to pin to, so the tab switches. Switching the tab's machine otherwise stays an explicit action (the tab's machine menu, or clicking a foreign lane).
Sync command scoping
The sync WebSocket service is owned by the ade serve runtime in normal desktop operation. ProjectScopeRegistry.ensureSyncHost selects the most-recently-opened registered project as the active sync project and refreshes that selection when projects are added or removed.
Desktop Connections controls first talk to the active runtime for status,
discovery, device registry, and PIN operations. Local project windows use
LocalRuntimeConnectionPool; remote project windows use
RemoteConnectionPool. The old desktop-host path is guarded by
ADE_ENABLE_DESKTOP_SYNC_HOST=1 for diagnostics and migration debugging.
When a packaged desktop is temporarily attached to an isolated local
project-capable runtime whose sync service is disabled, preload recognizes only
the specific Sync service is not available / Register a project first
errors and retries machine-level Mobile/Web status and controls through the
main sync IPC. Remote-bound errors never take this local fallback, so a failed
remote sync call cannot accidentally operate on the desktop's machine brain.
The sync command registry labels descriptors as runtime or project scope. Project-bound runtimes reject project-scoped commands that arrive without a matching projectId, while runtime-scoped commands operate on the ADE runtime as a whole. This keeps mobile/controller commands explicit in the multi-project runtime.
Paired transport & relay trust boundary
Besides SSH, a remote target can use the paired transport: after PIN + DPoP
pairing, the full runtime JSON-RPC rides the sync WebSocket as newline-delimited
frames inside rpc_* envelopes, and loopback TCP previews ride fwd_*
envelopes (host-side connect restricted to 127.0.0.1). Both are gated to
desktop runtime-host peers: the host only opens the RPC channel / port-forward
for a peer that authenticated with kind: "paired" and whose authoritative
stored pairing record has runtimeHostGranted: true. The Share Machine link
carries a short-lived, one-time server grant that is consumed only after the
PIN succeeds. Nearby desktop pairing over a direct LAN/tailnet socket may set
the same stored capability after a correct PIN; the host never applies that
exception to a Relay socket. The device type claimed in hello.peer is metadata, not
authorization: a paired phone or browser that reconnects while claiming
deviceType: "desktop" still stays on the mobile
command allowlist. If it sends rpc_open/fwd_open, the host closes the channel
with "Runtime channel is only available to desktop clients", and hello_ok
advertises features.rpcChannel/features.portForward as false. The host also
caps concurrent channels per peer (32 RPC channels, 64 forwards) so an
authenticated peer cannot exhaust file descriptors or memory.
Relay trust boundary. The sync WebSocket can reach the host over a direct
LAN/tailnet route or through the cloud tunnel-relay. The relay is a plaintext
byte pipe: TLS terminates at the relay and there is no end-to-end encryption
between the two ADE machines. Over a relay route the relay operator can read all
runtime RPC payloads and the paired secret that transits in the hello.
Direct LAN/tailnet routes do not transit the relay and are not exposed to it.
DPoP proves to the host that the connecting client possesses the private key
registered for that paired device; it protects the host from replay with only a
stolen paired secret. It does not authenticate the host to the client or prevent
a relay operator from impersonating the host. Treat relay routes as
trusted-operator paths, not confidential channels.
Relay authorization is enforced again at the host, not only in route selection:
every kind: "paired" hello arriving through the process-private
relay-bridge origin must include a short-lived Clerk access token whose sub
matches the host's current account. Direct LAN/tailnet paired hellos do not carry
or require this proof. First-time pairing_request messages over the relay use
the same proof before the host checks the PIN, so account failures do not enter
the PIN brute-force budget. Legacy kind: "bootstrap" hello messages are never
accepted over relay-bridge. The relay proof is intentionally not persisted.
The host also omits its relay candidate and stops the tunnel control connection
while signed out, then resumes it when a current account session returns. Because
the current tunnel is not end-to-end encrypted, the trusted relay operator can
also read this short-lived bearer; eliminating that exposure requires an
infrastructure-level opaque relay attestation or end-to-end encrypted tunnel.
Account-created host pairing records persist their Clerk owner id. The host
periodically refreshes its own account token as a short lease: sign-out, account
switch, expiry, or refresh failure removes records owned by the old account and
closes both those peers and every active Relay peer. PIN and local SSH pairing
write explicit local provenance, so they can declassify a same-device account
record and survive account changes. Older records without provenance are
treated as local for backward compatibility. A verified same-owner account
hello with the already-pinned DPoP key may rotate and return a fresh paired
secret; this makes credential delivery retryable when the previous hello_ok
was lost without allowing a different account or key to take over the record.
ADE Code saved-target resolution and launch budget
The desktop Machines panel and ade code remote share
RemoteTargetRegistry plus DesktopPairedMachineStore. Selecting an account
directory machine asks AccountMachineDirectoryService to perform account
adoption over the exact allowlisted WSS relay, persist the resulting paired
secret and DPoP reference, and save a transport: "paired" target with no SSH
fallback routes. Subsequent LAN/tailnet connections authenticate from the
paired store. Relay reconnect adds the ephemeral same-account proof described
above. Manual pairings remain account-independent locally; signing both machines
into the same account makes relay eligible without converting that manual record
to account-owned provenance.
Older desktop builds could save that account row as a credentialless routed SSH
target even though the desktop's own SSH stack happened to find a working local
identity. resolveRemoteTargetForLaunch recognizes only the high-confidence
legacy shape: no saved SSH user/key, non-manual routes, an exact name match, and
all saved hosts belonging to one account-directory machine. It adopts that
machine into the paired store and removes the obsolete record. An unavailable
directory, offline machine, or pairing failure is a terminal paired-transport
error for that shape. ADE never downgrades an account-created target to SSH;
only a route with source: "manual" is eligible for SSH fallback; Bonjour and
Tailscale discovery routes are never inferred as SSH configuration.
remoteLauncher.ts gives target resolution, account adoption, paired route
dials, and every SSH route × channel-home × absolute/PATH binary probe one shared
45-second startup budget. Every child process, WebSocket open, and paired hello
wait is bounded by the smaller of its normal per-attempt cap and the budget that
remains. SIGINT/SIGTERM abort the active wait and close the corresponding
child/socket; exhaustion returns the collected route/runtime diagnostics instead
of polling indefinitely. After project/session selection, a paired bridge
reconnect receives a fresh bounded startup budget; the deadline does not limit
the connected ADE Code session itself.
Local runtime routing
Local desktop windows go through the runtime binding. callProjectRuntimeActionOr and callProjectRuntimeSyncOr in apps/desktop/src/preload/preload.ts call the active local or remote runtime when a binding exists; legacy Electron IPC handlers are used only when no runtime route is bound or for desktop-only side effects. File actions are strict once a local or remote runtime is bound, which prevents a failed runtime-bound file write/read from being retried against the desktop's local filesystem when the bound project is owned by a daemon or remote host. Usage and budget reads use the remote runtime only for remote-bound windows; local-bound windows keep using desktop usage IPC. During project.switchToPath, preload temporarily binds local runtime calls to the requested root and main-process runtimeBridge.ts honors the explicit rootPath over the window session binding for local action, sync, and event-stream calls. During remoteRuntime.openProject, preload clears the binding while the switch is in flight; mutating runtime actions and mutating sync calls fail with the "Project is switching" message instead of refreshing or writing through a stale binding, while read-only project calls can wait for the active remote open and retry against the new binding.
Which local roots a window may address at all is a separate question from which one it is bound to, and runtimeBridge.collectAuthorizedLocalRuntimeRoots answers it from the window session. services/projects/windowTabRootAuthorization.ts (resolveWindowTabRoots) splits a renderer's set-open-tabs call into tabRoots — what the window is displaying, renderer-supplied and only ever used to look projects back up, so an unknown path there is harmless — and authorizedLocalRoots, the window's local runtime scope, which the bridge reads as "this window opened this checkout". Only roots that resolve to a project this process actually opened join the second list; a path must not earn local-runtime access by appearing in a tab list. main.ts also keeps a per-window windowKnownLocalProjectRoots set of every local root the window itself opened this session and surfaces it as knownLocalProjectRoots, because neither existing signal survives a rebind: windowProjectTabRoots is replaced wholesale by the renderer on every tab change, and windowProjectRoots is nulled the moment the tab binds to a remote machine. A chat pinned to "This computer" asks precisely that question — its lane lives on this machine whichever machine the tab is bound to — and refusing it left cross-machine Work views unable to act on their own local sessions. Membership is granted only by the window opening the project, never by a renderer-supplied path.
Read-only chat calls are deliberately left unhandled while a transition is in flight, which raises the question of what to do with them. For remote contexts the answer is not the local IPC fallback: the local main-process chat service has never heard of a remote session id, so it answers sessionFound: false — a false "this session does not exist" that the renderer treats as authoritative and uses to wipe the transcript and its cache. agentChat.getEventHistory and agentChat.getEventHistoryPage therefore return an explicit unavailable: true result ("runtime temporarily unreachable") so the renderer keeps what it has and re-queries once the switch settles; the page variant echoes the caller's beforeOffset back as startOffset so it does not also claim the head of the transcript was reached. Local bindings keep the IPC fallback, because there the local service is the right answer. See Chat transcript and turns.
That decision needs a synchronous answer to "is this window's project runtime remote?" at a moment when currentProjectBinding has been intentionally nulled. isRemoteProjectRuntimeContext() provides it without refreshing or awaiting, using three signals in precedence order: a live binding answers for itself; an in-flight remote openProject means the window is switching to a remote runtime; otherwise, only while a transition is in flight, it falls back to the kind of the binding the window was attached to when the switch began. That last value is snapshotted by detachProjectBindingForTransition() at detach time rather than being left at "the last non-null binding" — otherwise a window that closed a remote project and is now projectless would report "remote" forever, and every later local transition would answer "runtime unreachable" for projectless chats the local service can legitimately serve.
callPinnedRuntimeAction(pin, domain, action, request) is the explicit-binding escape hatch alongside the binding-resolving helpers. Instead of reading the mutable module-level currentProjectBinding, it routes against a caller-supplied OpenProjectBinding — addressing a remote runtime by targetId/projectId or a local runtime by rootPath directly — and bypasses the project-transition guard. It exists for in-flight work that must stay pinned to the project that started it even if the active project changes mid-flight: the originating binding is captured up front and the call cannot be misrouted to the now-active project. lanes.delete and agentChat.delete accept an optional pin?: OpenProjectBinding | null second argument that routes through this helper when present (used by draft-launch rollback — see Chat); when pin is absent they fall back to the binding-resolving path. The transition guard is skipped deliberately because a pin is only passed for explicitly-targeted, intentional cleanup, not for ambiguous active-binding calls.
Lane preview reads are also binding-aware. For remote bindings, proxyGetPreviewInfo is resolved on the remote runtime, then remoteRuntime.ensurePortForward creates or reuses a local 127.0.0.1:<port> TCP forward to the remote preview port and rewrites the preview URL before returning it to the renderer.
The runtime path covers:
- agent chat actions and chat event history
- terminal session list / detail / update / delete and transcript tails
- pty create / write / resize / dispose plus streamed data and exit events
- file reads / writes / search / quick-open / tree listing and file-watch subscriptions
- diff reads and most git operations
- lanes, PRs, native GitHub stacks, PR issue-resolution launch flows, PR AI conflict-resolution sessions, issue inventory, tests, and project config
Operations with desktop-only side effects, such as some automation hooks and UI-native flows, still use the in-process IPC handlers until their side effects are moved into ade-cli services.
Preload also guards two classes of API against remote bindings:
assertNotRemoteProjectPathActionrejectsapp.revealPath,app.openPath,app.openPathInEditor,app.getImageDataUrl, andapp.writeClipboardImagewhen the input path is the remote project root or any descendant of it. A remote project's filesystem is not mounted locally, so revealing or opening those paths on the desktop would point at the wrong machine.openPathInEditoris the exception when the payload is a valid SSH remote editor request (isRemoteEditorOpenRequest: hostname present andtransport !== "paired"): preload skips the local-path guard and main mintsvscode://vscode-remote/ssh-remote+host/pathorzed://ssh/host/pathinstead of spawning against the laptop path. Paired remotes stay blocked.assertLocalProjectHostActionrejects iOS Simulator window-state / window-source lookups on remote-bound windows; those need direct Electron / OS access on the host that owns the simulator.
Remote connection pool lifecycle
RemoteConnectionPool keeps one paired- or SSH-backed RuntimeRpcClient per targetId:
withEntryForTargetis the single funnel for all RPCs. On a recognized connection error, it disposes the entry, reconnects (via the latestregistry.get(targetId)so an updatedroutesarray applies), and either replays the operation or reports the connection error to the caller. A method timeout is not a connection error: it propagates to the original caller without reconnecting or replaying any operation.callProjectActionForTargetonly enables automatic replay after a genuine connection failure for safe read-only actions: prefixesdiagnosticsGet|get|list|oauthGet|oauthList|portGet|portList|proxyGet|read|searchplus a small allowlist (chat.codexFuzzyFileSearch,chat.fileSearch,chat.modelCatalog,file.quickOpen,terminal.activeForChat,terminal.preview).callProjectSyncForTargetuses the same posture for sync: status/discovery/device/PIN reads, lane-presence announcements, and model-picker reads are retryable after a connection failure; mutating action and sync calls are never replayed automatically, because ADE cannot know whether the remote side effect completed before the connection was interrupted.- Connection startup failures are backoff-throttled per target so repeated implicit reconnect attempts do not saturate SSH. Explicit Connect bypasses that backoff, clears manual disconnect state, and resets the automatic reconnect failure budget. After 10 implicit connection failures,
RemoteConnectionServicepauses automatic reconnect and reports that the user must press Connect. - Reconnection intent is persisted as
target.autoConnect, independent of account state. A successful explicit or implicit connection sets it totrue; Disconnect sets it tofalse; an explicit failure preserves the previous value. Launch/wake probing considers only targets where it is enabled (with a one-time legacy coercion from old last-connected/manual-disconnect fields). ensureLocalPortForwardowns local TCP listeners keyed by(targetId, remoteHost, remotePort). Each listener usesssh.forwardOutinto the active SSH session and is closed when the target disconnects.callMethodForTargetis the runtime-scoped JSON-RPC entry point used by the command-palette project picker and clone flows. Before forwarding to the SSH transport it runsassertMachineProjectCapability(entry, method), which checks the connection'scapabilities.machineProjectsmap for theprojects.*family. A missing capability fails the call with a self-describing message that names the action (e.g.creating remote projects) so the renderer can guide the user to update the remote runtime.- Optional remote actions can define compatibility fallbacks. Missing
file.refreshGitDecorationsreturns an empty decoration set and marksstatusHints.optionalActionMissing. The first not-callable response is memoized per target/project/action so old remote runtimes do not get hammered by unsupported optional calls. RuntimeRpcClient.callexpires each request independently: the timer atomically removes and rejects only that request ID, late responses for the expired ID are ignored, and unrelated calls plus runtime-event notifications continue on the same transport. Transport close/error and malformed JSON framing remain connection-fatal: they close the client, notify disconnect observers, and reject every pending request. Oversized response lines remain memory-bounded and reject only the matching request while the client discards the rest of that line. The client records the originalmethodfor every pending request and formats remote errors asRemote ADE service method <method> failed (code N): <message> Details: ...so JSON-RPC errorcode/message/dataare all preserved instead of being collapsed into a generic string.onEntryEvicted(listener)letsRemoteConnectionServiceflip a status toerrorwhen the paired/SSH transport or JSON-RPC client closes underneath it.runtimeBridge.tssubscribespowerMonitorresumeandunlock-screentoremoteConnectionService.probeSavedConnections(), which pings everyconnectedtarget with a shortpingTimeoutMs(default 5 s); a recognized transport failure disconnects the entry so the next renderer call reconnects against the most-recently-successful route, while a request-local ping timeout leaves the healthy shared client in place.
Per-target action registry
The renderer's command palette needs to know which action domains a target supports. ade.remoteRuntime.listActionRegistry { id, projectId } IPC calls RemoteConnectionPool.listActionRegistryForTarget, which invokes list_ade_actions on the remote runtime and normalizes the result into AdeActionRegistryEntry[]. Preload's ade.actions.listRegistry checks the active binding: remote-bound windows query the remote runtime, local-bound windows query the local ADE runtime through ade.localRuntime.listActionRegistry, and the desktop in-process registry is only used when no project runtime is bound.
Local runtime connection lifecycle
LocalRuntimeConnectionPool handles the desktop side of the local runtime binding:
connect()first tries an existing~/.ade/sock/ade.sock. If that socket is unavailable, it spawnsade serve --socket <path>with the bundled CLI fromprocess.resourcesPath/ade-cli/cli.cjsor the dev path, waits for the socket, and reconnects.initializeis called immediately after connect. The pool comparesruntimeInfo.version,runtimeInfo.buildHash, andruntimeInfo.defaultRolewith the expected desktop runtime. A mismatch closes only that client: the primaryade.sockdaemon is preserved, and the desktop starts or reuses a deterministic isolated socket (ade-cto-<version>-<build>.sock) for its own runtime instead of terminating the user's existing daemon.installServiceBestEffort()runsade serve --install-serviceonce per session to register the per-user login service; the result feedsLocalRuntimeStatus.serviceInstall.getStatus()periodically refreshesserviceHealth(unsupported | not_installed | installed | running | error | unknown) by callinggetRuntimeServiceStatus()from the service manager.- The pool exposes typed entry points for action calls (
callActionForRoot), sync calls (callSyncForRoot), event polling (streamEventsForRoot), and event subscription (subscribeEventsForRoot). All of them register the project withprojects.addonce and then carryprojectIdon every project-scoped request. - Project registration, file actions, and event polling pass per-call timeout overrides to
RuntimeRpcClient; ordinary actions keep the client's default timeout. A timeout propagates only to that caller: the local pool does not reset the shared client or replay the request, so concurrent calls and event subscriptions continue normally. Both pools also keep a smallLONG_RUNNING_*_ACTION_TIMEOUTSmap keyed by<domain>.<action>; today it grants both the compatibilitychat.suggestLaneNameFromPromptaction and the structuredchat.generateAutoLaneIdentityaction a 120 s budget instead of the default 30 s local / 25 s retryable-remote ceiling. Auto-lane naming still runs entirely after lane/session launch, so even an expired naming request cannot fail the launched work. callActionForRootmeasuresensureProject,connect, and the daemon RPC separately. Any action call that exceeds 500 ms total — or throws — emits alocal_runtime.action_slowwarning log withdomain,action,totalMs,ensureProjectMs,connectMs,daemonCallMs, the applied per-call timeout, and the error message (when present). This is the entry point for diagnosing why a renderer action stalled before the IPC timeout fires.