Remote Runtime

August 23, 2026 · View on GitHub

The desktop app connects to an ADE runtime (ade serve) running on another machine. The remote project lives on that machine; lanes, PTYs, git, agent chat, and PR actions all run there. The local desktop is the controller — it spawns no project services of its own for a remote binding.

The recommended transport is paired: sign in on both desktops for PIN-less account-directory adoption, or pair a Nearby machine with its six-digit PIN. Both paths create device-bound DPoP credentials, then carry the full runtime JSON-RPC over the machine sync WebSocket. ADE tries direct LAN routes, then tailnet routes, then the cloud relay when both machines are signed in to the same ADE account. SSH remains the Advanced path and a fallback only for paired targets that came from an explicitly configured SSH route. It runs the same JSON-RPC over an SSH exec channel using ade rpc --stdio and can upload or start the remote runtime when needed. The relay is a trusted-operator plaintext path, not an end-to-end encrypted tunnel; adding relay payload E2E encryption is planned security work. See the trust boundary in Internal architecture.

Source file map

  • apps/desktop/src/main/services/remoteRuntime/ — paired transport (syncRuntimeTransport.ts), loopback preview forwarding (syncPortForwardClient.ts), paired credential and endpoint history (syncPairedMachineStore.ts, whose endpoint list is append-only except for forgetEndpoint: an address that provably answered as a different host is dropped rather than demoted, because markEndpointFailed re-adds whatever it records a failure for and the recently-failing demotion expires after two minutes — a neighbour's tailnet name that once landed in the record would otherwise be retried forever. The record's own endpoint always survives, so a machine can never be left with nothing to dial), LAN → tailnet → relay ordering (pairedRuntimeRoutes.ts), typed handshake rejections (pairedRuntimeErrors.tsPairedRuntimeHelloRejectedError carries the host's structured hello_error.code and the rejecting host's identity, so classification never pattern-matches the host's prose), paired bootstrap and connection diagnostics; plus the Advanced SSH transport (multi-route fallback, bounded connect/exec timeouts, strict host-key verification, normalized handshake errors) and runtime upload/bootstrap. The folder also owns the target registry, runtime RPC client (request-local timeouts with connection-fatal transport/protocol failures), remote connection pool (paired-first with eligible SSH fallback, eviction listeners, connection-failure retryable reads, preview forwards, optional-action fallbacks, event-stream gap/epoch propagation, route-pinned sensitive action dispatch, unknown-outcome errors for non-replayable actions that lose confirmation, and capability-gated handoff storage preflight), connection service, and Bonjour + Tailscale discovery.

  • apps/desktop/src/main/services/remoteRuntime/remoteSidecarCache.ts — the release-asset fallback for the ade-<target> sidecar a target-only desktop package does not bundle: version-pinned download, SHA256SUMS verification, atomic publish into <ADE home>/runtime-sidecars/<tag>/<target>/, and typed unavailable/network/checksum failures. Reached only through resolveBootstrapRuntimeSidecars in remoteBootstrap.ts. Shares its asset naming and checksum parsing with apps/ade-cli/src/lib/releaseAssets.ts.

  • apps/desktop/src/main/services/ipc/runtimeBridge.ts — runtime IPC boundary: remote target registry, connect / projects / project-open channels, remote action/sync/event dispatch, local-runtime project action/sync/event routing, local port-forward creation for remote previews, per-target action registry lookups, replay-aware event streams, manual disconnect handling, and per-window remote-open generation guards so a slow earlier remote-project open cannot overwrite the latest window binding. It also registers ade.runtime.events.release, the renderer's explicit teardown for a subscription it has stopped reading, and ade.remoteRuntime.updateAndRestart, the desktop half of the host's machine.updateAndRestart runtime method (see machine.updateAndRestart). Two members exist for main-process callers that have no project binding at all: callMachineMethod is machine-scoped RPC to an already-paired target — used by the account-wide usage merge, which asks each machine for its own rollup — and deliberately does not widen the separate method allowlist IPC.remoteRuntimeCallSync keeps for the renderer; isTargetConnected (backed by RemoteConnectionService.isConnected) lets an opportunistic background reader skip a disconnected target, because a failed call there spends that machine's automatic-reconnect budget and exhausting it pauses automatic reconnect for every feature the target serves. The local machine identity those callers key on moved to services/account/localMachineIdentity.ts — Electron-free, so the usage rollup publisher and the CLI-hosted brain resolve the same machine key without importing this bridge — and is re-exported here for existing callers.

  • apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts — the store behind those streams. Subscriptions are keyed by (sender, requestKey = <bindingKey>:<category|*>:<replay|live>), because one window runs several pumps at once (active binding, one pinned PTY pump per foreign lane, one pinned chat pump) and keying by sender alone would make each new pump tear down its siblings. Stale entries are reclaimed by idle expiry (refreshed on every poll, swept every 20 s at a 60 s idle threshold) with the renderer's release as the fast path. Every caller — release, the ended callback, remote disconnect, sender death, the sweep — removes through one function, so disposal and pruning cannot drift apart, and cleanup functions are attached through an atomic check so a subscription replaced mid-flight never adopts a disposer the registry could not run.

  • apps/desktop/src/preload/pinnedRuntimeEvents.ts — the renderer-side half: every event pump that reads a binding the window is not bound to. It owns one shared PTY pump per pinned binding (polling plus ade.runtime.event push delivery, with its own cursor, epoch generation, dedupe ring, in-flight epoch-rewind guard, and failure backoff), the per-listener generic pump used by pinned chat/project subscriptions, and the helpers the active pump in preload.ts shares. Main-side subscriptions are reference-counted per (binding, category) so sibling pumps share one and only the last teardown releases it; the active pump never retains a reference and therefore never releases a subscription a pinned pump is still reading.

  • apps/desktop/src/main/services/account/accountBridge.ts and apps/ade-cli/src/services/account/accountMachineDirectoryService.ts — account-directory adoption. The desktop Machines row and packaged CLI both turn an online account machine into the same paired-machine credential record and paired-only remote target; account credentials are not retained as an alternate transport.

  • apps/ade-cli/src/services/account/accountMachinePublisherService.ts — the producer side: the host publishes its own directory row so a same-account desktop or CLI can adopt it. Only currently validated routes are advertised — a lan endpoint per live LAN address (a saved lastHost that matches the current LAN/Tailscale set is now classified as lan/tailscale rather than the opaque saved kind, so LAN endpoints publish correctly instead of being dropped), a tailnet endpoint per reachable Tailscale address, and a relay endpoint once the tunnel bridge is validated and an end-to-end self-probe confirms the relay path round-trips (relayEndToEndVerifiedAt with no failure). Bridge validation is now proactive (syncTunnelClientService.validateCurrentBridge, run on control-open and on listener-ready), so the relay endpoint appears in the directory as soon as the machine is signed in, its listener is confirmed, and the self-probe passes — it no longer waits for an external client to open the first relay tunnel. The row also carries the host's Ed25519 identity as pubkey, which same-account clients verify before a sealed ade-adopt-v1 adoption over a direct route (see the Sync security model). The published machine name is channel-suffixed (<name> · Beta / <name> · Alpha, stable left bare) so two channels on one computer are distinguishable rows.

  • apps/ade-cli/src/services/sync/syncTunnelClientService.ts and apps/ade-cli/src/bootstrap.ts — the relay side of a paired route. The tunnel client is shared one-per-machine (getSharedSyncTunnelClientService, keyed by sync-cloud-relay.json); bootstrap builds a relayTunnelAuthorityGate per project scope, which starts the tunnel only while this process holds the machine-wide sync host lease and hands the shared listener to attachHostListener() outside the construction factory, so the runtime that owns the listener supplies the port, loopback nonce, and bridge proof regardless of which runtime created the instance. See Relay tunnel and the sync port below.

  • apps/ade-cli/src/services/sync/syncHostService.ts — the host end: paired hello authentication (with the sync_host.paired_device_rejected / sync_host.paired_account_owner_mismatch rejection logs and the frame-count-aware peer-close logs) and tailnet publication, including staleAdeTailnetServePorts / reclaimStaleTailnetServes, which retire ADE's own leftover tailscale serve entries after each successful publish.

  • apps/ade-cli/src/commands/doctor.ts — the Sync port row names a drifted port and its base-port holders, and says explicitly that a root-owned holder such as tailscaled is invisible to this probe rather than reporting the ports as free.

  • apps/ade-cli/src/tuiClient/remoteLauncher.ts, pairedRemoteConnector.ts, remoteLaunchBudget.ts, and remoteBridge.tsade code remote target resolution, legacy account-target migration, paired/SSH launch ordering, bounded cancellation, project/session selection, and the one-connection local socket handed to the normal ADE Code client.

  • apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts — the local runtime connection used by desktop IPC, event streaming, sync Settings, and local-work checks. Runtime initialization advertises an integer compatibility window (minCompatibleProtocol through protocolVersion). A desktop connects normally to a newer machine brain when its own RUNTIME_COMPAT_LEVEL falls inside that window; a newer incompatible brain remains preserved and the old desktop window falls back to an isolated no-sync runtime. It also spawns ade serve for non-primary sockets, tracks the per-user login service install/health state, and applies short per-call timeouts for project registration, file actions, and event polling so renderer IPC calls do not wait for the desktop handler timeout. LocalRuntimeStatus now also carries the brain's pid, its bound syncPort, the account-directory publishHealth slice (state + failingSinceMs + last-leg durations), and the one-shot lastWedge recovered by the event-loop watchdog. probeMachineRuntimeIdentity() is the side-effect-free question "what is answering on this machine's endpoint, exactly": a MachineRuntimeIdentity carrying version, build hash, pid, the build hash this desktop expected, and whether the answering brain is a compatible newer one. It replaces the older probeMachineRuntimeHealth(), which answered ok and thereby could not tell a correctly updated brain from the pre-update one still holding the socket — see desktop auto-update. The pool's own opportunistic service repair is now both throttled and suppressible. serviceRepairBackoffMs spaces attempts at 0 / 5 s / 15 s / 30 s / 60 s, capped at one a minute, because a mismatched runtime previously spawned one installer per connect attempt — that is per failing action poll. beginUpdateWindow / endUpdateWindow suppress repair entirely for at most LOCAL_RUNTIME_UPDATE_WINDOW_MAX_MS (2 minutes) while an update is applying, and connects refused during it get LOCAL_RUNTIME_UPDATE_IN_PROGRESS_MESSAGE ("ADE is applying an update. The background service is restarting.") rather than a repair. The window self-expires, so a transaction that never settles cannot leave recovery disabled for the session. getStaleMismatchedRuntime() reports a still-running pre-update brain, verified alive and start-time-matched through readProcessStartTimeMs so a recycled pid is never mistaken for it, and never reports a brain newer than this desktop. The Machines panel renders publish health as a This-computer indicator (remoteMachineModel.describePublishHealth, which reads inactive states as "none" and only alarms a real failure after it has persisted ~2 minutes), and the app shell reads lastWedge for the BrainRecoveryNotice banner. Both serve --install-service and serve --uninstall-service run through one shared runServiceManagerCommand child-process boundary (spawn, output accumulation, single-settle latch, timeout kill, output parse) and differ only in the policy applied to the result. Install is bounded at RUNTIME_SERVICE_START_WAIT_MS (90 s) and uninstall at 20 s: a wedged installer used to pin serviceInstallPromise forever, which blocked every later install and left Repair spinning, but the budget errs long because it has to cover the installer's own handover wait — a child killed at this deadline reads as a failed install even when the supervisor's replacement is coming up. The parsed result carries starting and restarted through to LocalRuntimeStatus.serviceInstall, and a starting install is logged as local_runtime.service_install_starting rather than as a success. The status also carries attemptStartedAt: the start of the current streak of install attempts, not of this attempt, because installs recur (every connect failure re-runs one, isolated recovery re-runs one every 60 s) and what recovery has to age is how long the brain has failed to answer since the first of them. It resets on a successful connection, and it is what projectRecoveryService.diagnose measures its brain_starting window against. installServiceBestEffort coalesces concurrent callers onto one child, but a forceRestart: true call never coalesces onto a plain install — a background install may skip entirely or may have spawned before the user asked for a restart, so returning its promise would report success without restarting anything. A forced call queues behind whatever is in flight, runs its own forcing install, and becomes the promise later callers coalesce onto.

  • apps/desktop/src/main/services/runtime/lastFailureStore.ts — bounded typed project/machine failure reports used when the background service exits before desktop IPC can obtain a normal runtime error.

  • apps/desktop/src/main/services/runtime/projectRecoveryService.ts — brain-independent project diagnosis and ordered repair for storage, database, migration, endpoint, and chat continuity failures. It also owns restartBrain(), the machine-scoped restart behind the Connections Repair button. Both it and repair()'s restart_service/verify_endpoint steps go through one restartServiceAndWait() sequence — install, wait up to 90 s for the machine endpoint to rebind, then ping — which reports which stage lost rather than the copy, because its two callers phrase the same stage differently (repair() speaks in repair steps, restartBrain() throws). force is more than the install flag: a forced restart is the only caller that actually asked for one, so an install that resolves having skipped is a failure for it, and its message becomes "A newer ADE runtime is already running — quit and reopen ADE instead." (the release-build block is passed through verbatim, since it is already written as instructions) rather than the installer's log line. The two are mutually exclusive: restartBrain() rejects with "Recovery is already running." while a repair() is in flight, because repair stops the service and then does exclusive database work, and reinstalling the brain underneath it would put a writer back on the database mid-check. Repair wins; the button can be pressed again afterwards.

  • A slow brain is not a broken brain. The brain binds ade.sock before it starts the mobile sync host (runServe in apps/ade-cli/src/cli.ts starts runSyncHostStartupLoop in the background right after the socket is published), so a desktop can reach it within a second or two of spawn even while a project scope is still opening or the sync port band is being reclaimed. All three installers — launchd, systemd, and Windows — wait RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS (30 s; WINDOWS_HANDOVER_TIMEOUT_MS, 15 s, on Windows) for the replacement to answer and, if it is alive but still quiet, return ok: true, starting: true instead of a replacement_responsive failure — the supervisor owns that child and it will answer. Every budget in this lifecycle is defined once, in apps/ade-cli/src/serviceManager/runtimeServiceBudgets.ts, because the numbers only mean anything relative to each other (the desktop's wait has to outlast the installer's). The shared handover itself — responsiveness probe, young-brain age check, crash-loop veto, the wait loop and the young-brain decision — lives in apps/ade-cli/src/serviceManager/serviceHandover.ts (awaitServiceHandover, awaitYoungBrainStart) so launchd and systemd cannot drift; each installer keeps only its own message text. The young-brain wait and the real handover get a full budget each: when they shared one install-wide deadline, a young brain that died late in its wait left the restart with no time and its replacement was reported as a replacement_pid failure. installSystemd.ts reads unit state from a single systemctl --user show -p ActiveState -p MainPID and treats systemd's own activating as a live brain. The desktop then keeps dialling the socket for LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS (90 s). A forced install (Repair) that finds an unchanged agent whose child is younger than RUNTIME_SERVICE_YOUNG_BRAIN_MS (120 s) and not answering yet waits for that child rather than killing it — restarting a booting brain only resets its clock, and doing it on every Repair click was how a slow machine could never finish starting one. A brain that keeps dying is always young, so a recorded crash-loop streak vetoes that wait: it needs the restart and the diagnosis, not more patience. projectRecoveryService.diagnose reports the same window as brain_starting (no Repair offered; the recovery screen re-diagnoses every 2 s and reopens the project itself once healthy), and repair() streams each step to the window via IPC.recoveryRepairStep so a long restart wait reads as progress, not a hang. Before this, a 10 s handover budget expired against healthy-but-slow brains on cold or slower machines, the update transaction reported "couldn't be set up", and the recovery screen's Repair killed the brain that was seconds from ready.

  • Linux parity. ade on Linux (headless brains, install.sh installs, remote runtimes reached over SSH) goes through installSystemdService, which before this used to write the unit, enable --now, restart, and report success the moment systemd accepted the restart — no proof the replacement ever answered and no way to say "installed, still starting". A remote bootstrap then dialled a socket that was not up yet and read a healthy-but-slow brain as a broken one. It now runs the same handover as macOS: no-op when an unchanged unit already answers, wait rather than restart a child younger than RUNTIME_SERVICE_YOUNG_BRAIN_MS (vetoed by a crash-loop streak), and starting/restarted/failureStep on the way out.

  • Windows: "still starting" needs proof of a supervisor. The readiness probe (serviceManager/windowsSupervisor.ts) returns supervised alongside ready, and only sets it once the recorded pid is verifiably our supervisor — alive, and a PowerShell running this launcher. A bare process.kill(pid, 0) would not do: the pid comes from an on-disk record, a recycled pid belongs to an unrelated process, and isPidAlive reports EPERM (someone else's pid) as alive. Calling a recycled pid "still starting" turns a genuinely failed install into an ok: true the caller waits on and never repairs. An unanswerable query is likewise never supervised: unknown must not read as healthy.

  • A brain that loses its socket ends itself. monitorBrainSocketOwnership (apps/ade-cli/src/cli.ts) remembers the inode it bound and polls the path. Binding before the sync host removed the incidental protection the startup loop's socket-liveness abort gave: two brains that both probe the same stale socket can race across the unlink/listen await, and the loser ends up listening to an inode nothing can reach without ever seeing EADDRINUSE. Losing the inode now ends the brain, the supervisor restarts it, and the restart reports socket_owned_by_other instead of squatting silently. The shutdown that follows removes the socket file only if it still owns that inode (unlinkOwnedRuntimeSocket) — an unconditional unlink here deleted the winner's socket and left the machine with a live brain nothing could reach. Windows named pipes are exempt — a pipe name has no directory entry to steal.

  • apps/desktop/src/main/services/runtime/machineTrustResetMigration.ts — one-time packaged-release reset of the old machine-connection trust files. It preserves account auth, machine identity, pairing PINs, projects, and SSH configuration, and completes only after the background service restart is confirmed — specifically, only when the install reports restarted. A forced install may legitimately decline to restart a brain that is still starting (it waits for it instead), and that brain loaded the pre-reset files, so leaving the marker unset is what makes the next launch restart it for real.

  • apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts (codedRecoveryError) — refuses to start an app-owned brain on a primary service socket and carries the recorded AdeRecoveryErrorCode to IPC and the renderer recovery surface.

  • apps/desktop/src/renderer/components/remoteTargets/ConnectionRouteDetails.tsx — the collapsed Details list under a connection failure. The headline is one sentence; this is where every attempted route, its host/port, its duration and the diagnostic id live. Failure reasons render as plain words (no answer, timed out, not signed in, pairing rejected, wrong machine answered, another route won, unsupported, version mismatch, failed) mapped from RemoteRuntimeConnectionAttempt.failure, never re-derived from message text.

  • apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx — the error card. Beyond Try again it accepts one optional action: a single explicit recovery step for failures retrying cannot fix. Today that is Pair again, offered by RemoteTargetList only when the rejecting machine is also a row in this ADE account's directory (there is otherwise nothing to re-adopt); pressing it re-runs account.pairMachine. Nothing re-pairs on its own.

  • apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx and remoteMachineModel.ts (accountMachineMatchesTarget, isMachineVersionOutdated, newestKnownAdeVersion) — the Update & restart button on a connected machine that is behind. "Behind" is measured against the newest ADE this computer knows about — what it runs, or a newer build it has already seen published — using the shared compareUpdateVersions; a machine level with us gets no button. The click calls window.ade.remoteRuntime.updateAndRestart(targetId, targetVersion) and renders the host's step-attributed message in the row.

  • apps/desktop/src/renderer/components/remoteTargets/ — Machines panel with connected / available / unavailable sections, Pair and SSH entry paths, share-this-machine and connection-doctor cards, saved/discovered machine rows, route and latency status, SSH host-key trust, structured connection errors, project picker, and the This-computer route-publish health indicator. A connect stopped because the host key still has to be confirmed is not a failure, and blockingHostKeyTrust reports it as its own outcome (needs_trust / changed) rather than through onError, so callers reveal the trust prompt instead of showing a machine row an error it did not have. Connection failures also carry a Report issue button. remoteMachineModel.ts (describePublishHealth) is the pure classifier for that indicator: the publishing published state reads healthy, the non-publishing states (sync_disabled, sync_not_started, not_host, account_signed_out, machine_key_unavailable, …) read as "none", and every other state is a failure that only alarms once it has persisted at least PUBLISH_FAILING_ALARM_MS (2 min) so a transient blip stays quiet. When that failure is specifically the brain-side unreadable account session (isBrainAccountSessionFailure, i.e. token_unreadable), the row also mounts the shared BrainRepairButton / useBrainRepair pair — the same control the Connections This Mac card renders, reading the same publisher health record, with the periodic getInfo poll extracted as a named refreshPublishHealth so a settled restart re-reads it immediately. Discovery diagnostics are rendered by severity rather than lumped together: RemoteRuntimeDiscoveryDiagnostic.severity is "warning" (discovery is degraded and worth looking at) or "info" (a normal, non-actionable fact about the environment). "Tailscale not installed — LAN discovery only." is info, because Tailscale is optional and not having it is not a problem; tailscale-timeout and tailscale-status-failed remain warning. The panel keeps the raw diagnostics array as its one source of truth and derives the warning line and the muted info note from it, so the two cannot drift; a failed listDiscoveredMachines call stays a separate string rather than becoming a synthetic diagnostic.

  • apps/desktop/src/renderer/components/app/projectTabGrouping.ts — collapses the open local and remote tabs into one group per repository, joined on the normalized git origin. A project with no resolvable origin is never merged, and at most one checkout per machine joins a group (a lane worktree shares its parent's origin, so merging them would produce a tab that cannot represent both). apps/desktop/src/shared/projectIdentity.ts owns the binding-key format the join and every per-project cache are keyed by. TopBar.tsx renders the group as one tab plus a machine menu; the machine name only earns inline space when it is ambiguous (more than one machine in the group, or a checkout that is not on This computer), and the menu also offers Connect another machine….

  • apps/desktop/src/shared/machineIdentity.ts — the single definition of "the machine ADE is running on": THIS_MACHINE_ID ("this-mac"), THIS_MACHINE_NAME ("This computer"), isThisMachineId, and machineDisplayName. Every producer and consumer of a machine id imports from here — laneMachines.ts, projectTabGrouping.ts, crossMachineLanes.ts, LaneGitActionsPane.tsx, the composer, and the Chats page — because the push-divergence guard decides "is this another machine?" by comparing ids, so two spellings of this machine make it warn that This computer diverged from itself. The name is deliberately platform-neutral — ADE also runs on Windows, where "This Mac" was simply wrong. Machines are named absolutely ("This computer", "MacBook Pro (97)"); "remote" is never a machine name, since the machine a tab is bound to can change and the create-lane dialog already uses "remote" for the git base-branch source. machineNameForBinding(binding) is that absolute name for the machine a call-routing binding targets. A null binding is the tab's own machine — which for a chat is exactly the unpinned path — so it names "This computer" the same way a local binding does. A remote binding prefers the runtime's own reported name and falls back to the project tab's display name; neither branch can produce the word "remote".

  • apps/desktop/src/main/services/projects/recentProjectSummary.ts — reads origin's URL straight out of the repo's git config (no git subprocess per recent) and attaches it to each recent summary, which is the join key the tab grouping above uses. parseGitOriginUrlFromConfig / cleanGitConfigValue undecorate the value the way git does (quoted strings with \ escapes, unquoted ;/# comment tails), resolveGitConfigDirectory walks a linked worktree's <main>/.git/worktrees/<name> metadata dir back to the main repo's config structurally rather than by substring, and the parsed URL is cached per project root keyed on the config file's mtime because recents are re-summarized on every focus.

  • apps/desktop/src/renderer/state/crossMachineLanes.ts and the crossMachineLanesByMachineId / crossMachineLaneScopeKey slice of appStore.ts — the cross-machine Work union. Lanes carry the machine (lanes.worktree_path is an absolute path on exactly one machine) and chats inherit theirs through laneId, so the union is keyed by machine and holds lanes; there is deliberately no per-chat machine field. Refreshes are driven by the connection-snapshot subscription and existing lane-lifecycle / session-changed events (coalesced), plus a fallback loop for machines that publish no renderer change feed. That loop is visibility-gated: it stops entirely while the window is hidden and refreshes once on the way back, re-reads chats every 10 s, and gives the lane list its own 30 s cadence because lane.list with includeStatus resolves a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once — ids a completed read did not explain are remembered, since session.list does not filter on lane status while lane.list excludes archived lanes, and a chat on an archived lane is permanently unresolvable. Foreign reads are bounded, timed out, capped at four machines in parallel, and never gate the local list. A machine that drops is dimmed, not deleted: its lanes and chats stay on screen, collapsed and inert, with the offline form of the machine marker naming it. Believing a drop takes a completed, failed reconnect attempt (connecting observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes: every redial publishes connecting and a single failed liveness ping flips a target to error, so a shorter rule dims the sidebar on every wifi blip. Two states skip the wait for an attempt that is never coming and dim on the floor alone — an idle target, which will not redial at all, and a machine that is connected but cannot re-prove this repository, which answers but is never read for it. The second is dimmed and not removed: absence of proof is not proof of absence, and a project list that has not caught up after a reconnect must not read as "the repo is gone". Coming back is applied instantly, and the verdict survives a Work-tab remount — a dimmed machine brightens only by becoming eligible again, never because the runtime that held its drop record was torn down. Rows leave for three reasons: the machine is gone from the connection snapshot (unpaired or removed), it positively reports the repository missing and there is a resolvable origin to prove that by, or it has been unreachable for 24 hours. The origin requirement is what keeps a healthy machine's rows alive while the bound machine blips — repoMatchFor will answer "missing" off a folder-name mismatch alone, and the scope's origin URL is re-resolved from the bound machine, so it can be transiently null. Deleting rows on that evidence is not recoverable. The union is scoped per repository, so switching project tabs invalidates it wholesale. selectOtherMachineBranchStates is the derived-state seam the push guard reads at click time. The same module owns the pin-resolution hooks every chat-scoped surface resolves its machine through:

    • machineEntryForBinding(state, pin) / useMachineEntryForBinding(pin) — the pinned machine's slice of the union. The join is by binding key, not machine id: machineId is only known once that machine has answered, while a pin carries its routing target from the moment a chat is selected.
    • useForeignSessionLaneId(sessionId, presentLocally) — a chat selected from another machine is absent from this tab's session list, so its lane, and with it its machine, is knowable only from the union. presentLocally short-circuits the scan for the common case where the tab already holds the session.
    • useLanesForPin(pin) — the only lane list a pinned lane id may be resolved against. Never state.lanes: lane ids are unique per machine, not globally, so falling back to the tab-bound machine's list can match a different lane that happens to share the id and then hand its worktree path to a tool about to drive the other machine. A machine that has not been read yet yields an empty list, which surfaces as "not found" rather than as the wrong lane. It returns null for an absent pin so callers keep their own unpinned source explicitly rather than by accident, and it reads each half from the store that owns it: the union from the root store, the warm laneCacheByProject lane cache from the surrounding project-scoped store.
    • Root-store invariant. crossMachineLanesByMachineId is written only to the root store — every writer goes through rootAppStoreApi — and createProjectAppStore does not copy it, so a project-scoped useAppStore read sees an empty record forever. That is why these are useRootAppStore hooks, and why a React caller must not pass a useAppStore state into machineEntryForBinding.
  • apps/desktop/src/renderer/lib/chatMachineRouting.tsper-session runtime routing. buildLaneBindingIndex folds each open binding's lane list into a lane→binding index (active binding first wins), and resolveChatRuntimePin returns the OpenProjectBinding a session's calls must target, or null when it already lives on the active binding. collectOpenProjectBindings and buildChatMachineRoutingState are the shared constructors both consumers use so the two surfaces cannot drift into different definitions of "open" or of lane precedence. isLivePinnedBinding asks whether a pin is still open rather than whether it is active, because a pin differing from the active binding is now the normal state of any session whose lane lives on another open machine. Clicking such a row streams it from its own machine without rebinding the tab, which would otherwise drag Lanes / PRs / Files / Git / Run along with it.

  • apps/desktop/src/renderer/components/chat/AgentChatPane.tsx and apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts — the two routers built on that module. The chat pane resolves its own pin from the chat's lane; the Work hook is the Work tab's single routing authority for CLI/shell rows, adding lane-then-launch-pin resolution (pinForSession) and the launch-pin registry writes (rememberSessionPin / forgetSessionPin) on top of the shared router. The Work tools pane (Terminal / Git / Files / iOS / App Control / Browser) follows the active Work session's machine through a runtimePin prop off that same hook, so a chat on another machine gets that machine's git, terminals, and files rather than the tab's.

  • apps/desktop/src/renderer/components/chat/ChatRuntimeScope.tsx — the canonical chat-machine derivation, resolved once per pane. AgentChatPane runs useChatScopeDerivation (session → lane, via useForeignSessionLaneId when the chat is not in this tab's list → pin, via the chat machine router) and hands the result to ChatRuntimeScopeProvider, which covers its whole panel/drawer subtree. The pane and every chat-scoped tool — Git toolbar, iOS simulator, App Control, built-in browser, file changes, PR pane, terminals — therefore cannot disagree about which machine the chat is on, which is the failure mode a global useAppStore selector produces by default: it answers with the tab's machine, wrong in exactly the case that matters. useChatRuntimeScope() returns { pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online }; pin === null means, and only means, "this chat lives on the tab's binding", so the unpinned call is byte-for-byte what it was before per-chat routing. Outside a provider the hook yields the unpinned, local, online fallback, which is how a chat-less surface should behave. useChatRuntimeScopeForPin(pin, laneId, bindingOverride?) serves surfaces reused outside a chat pane — the CLI session header mounts ChatGitToolbar with its own pin and no provider above it — so the derivation comes from a prop instead of context. Lane rows and worktree paths come from useLanesForPin, never from the tab's lanes; online is false only when the chat's pinned machine is known unreachable, since the bound machine's liveness is the window's problem rather than this chat's.

  • apps/desktop/src/renderer/components/lanes/laneMachines.ts, LaneMachineSelector.tsx, and PushDivergenceDialog.tsx — machine selection during lane creation and the push-time divergence warning. deriveLaneMachineOptions matches each machine's checkout of the repo by normalized git origin (matchedBy: "origin", proof) or, failing that, by folder name (matchedBy: "name", a guess that must not on its own rebind the app). CreateLaneDialogHost captures the binding the dialog opened on and restores it if the dialog is closed without creating a lane, so browsing machines cannot silently leave the window pointed somewhere else.

  • apps/desktop/src/shared/laneDivergence.ts — the push-time guard. toMachineBranchState builds its inputs from lane records the renderer already has (LaneSummary.branchRef + LaneStatus.ahead/behind from LaneListSnapshot / lane_state_snapshots). The rule is grounded in ahead, not head commits: no lane record in ADE carries a head sha, so a rule that required one could never fire. Another machine holding the same branch with ahead > 0 holds commits that are by definition not in the push, so moving the upstream tip would strand them. Head shas only ever silence the guard (two machines proven to sit on the same commit); an unknown head never suppresses a warning, because this guards a destructive push. It stays silent when no other machine holds the branch, the entry is this machine (ids compared, never names), the other machine is on a different branch, or the other machine has nothing unpushed.

  • apps/desktop/src/renderer/components/chat/thisMachineProjectRoot.ts — resolves the machine picker's "This computer" option back to this repository's local checkout by repo identity (reusing deriveLaneMachineOptions' rule) rather than to the first local tab in insertion order, and refuses to switch when there is no local counterpart. Used by the chat composer's machine picker and the Chats tab.

  • apps/desktop/src/renderer/state/appStore.ts, apps/desktop/src/renderer/components/app/App.tsx, TopBar.tsx, and projectRouteStorage.ts — represent every open remote binding as an independent project surface. Work, lane, session, route, layout, and terminal-runtime identity use the binding key rather than the remote filesystem path. Local and remote tabs share one eight-surface warm LRU: inactive mounted surfaces are inert and animation-paused, while older open surfaces snapshot their state before unmounting. Returning to a tab restores its cached surface and revalidates lanes; a failed reconnect leaves that stale surface visible. Closing or disconnecting switches away first, then evicts only the affected binding — but the two are not the same eviction:

    • Explicit tab close, and removing a machine, are deliberate "forget this surface" actions: evictProjectState(bindingKey) plus removeStoredProjectRoute(bindingKey) wipe view state, data caches, and the remembered route.
    • Disconnect is temporary — the machine can come back — so it takes the narrower evictProjectDataCaches(bindingKey) path: lane cache, lane selection, and session cache are dropped (they can be stale against a remote that changed while it was unreachable) while workViewByProject / laneWorkViewByScope and the stored route survive, so reconnecting lands on the chat or tile the user had open. Binding keys are deterministic (remote:<targetId>:<projectId>), so the preserved view state re-attaches. When the disconnected tab was the last one, closeProject({ preserveRemoteViewState: true }) applies the same rule instead of wiping.
  • apps/desktop/src/preload/preload.ts — routes runtime-backed renderer APIs to local or remote JSON-RPC actions based on the active project binding — or, for a chat that lives on another open machine, based on an explicit OpenProjectBinding pin passed as a trailing argument (callPinnedOrBoundRuntimeActionOr). Chat and session APIs (agentChat.send / steer / interrupt / approve / getSummary / recoverTurn / …, sessions.get, sessions.readTranscriptTail) accept that optional pin; when it is absent the call is byte-for-byte the bound path it was before per-chat routing, with no extra await. The pin surface extends to every domain a chat-scoped tool drives, because the simulator, the controlled app, and a run's captured artifacts all live on the machine that owns the lane:

    • the iOS simulator domain (getStatus / listDevices / listLaunchTargets / launch / attachToChatSession / shutdown / screenshot / getScreenSnapshot and the rest), App Control, and the computer-use artifact reads and repairs (deleteArtifacts / recoverArtifact / readArtifactPreview). These route through domain-bound wrappers — callIosSimulatorActionOr, callAppControlActionOr, callComputerUseArtifactActionOr — so the domain string is not retyped at ~50 call sites, where a typo is a silent "unknown action" at runtime, while each method stays statically greppable by name.
    • the cross-machine handoff trio agentChat.prepareCrossMachineHandoff / validateCrossMachineSource / markCrossMachineHandoff, which are facts about the chat's machine and so take the pin rather than the unpinned callProjectRuntimeActionOr.
    • Pinned event subscriptions. iosSimulator.onEvent and appControl.onEvent take the pin and follow their reads to the pinned runtime's stream. Without that a pinned panel got status reads and no live updates, and the bound machine's stream described a different simulator entirely. builtInBrowser.onEvent is the deliberate exception: the built-in browser is hosted by this desktop's main process (it owns a WebContentsView) and the runtime daemon only proxies calls into it over the desktop bridge socket, so a pin naming 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, because those calls land on that desktop's browser.
    • Read caches are namespaced by binding. The preload process is shared by every machine a window talks to, so a read cache keyed by arguments alone is machine-blind: once an action can carry a pin, one machine's rows could be served to — or overwritten by — another's. boundReadCacheKey() prefixes each cached read's key with the current binding's key. The iOS simulator status and device caches became keyed caches for the same reason, and a pinned getStatus / listDevices bypasses the cache entirely with a direct callPinnedRuntimeAction.
    • The bridge object is contract-checked. It is declared const adeBridge = { … } satisfies Window["ade"] before contextBridge.exposeInMainWorld, so a preload signature that drifts from the declared global.d.ts contract — a missing pin parameter above all — is a compile error rather than a renderer silently talking to the wrong machine.

    Remote project usage/budget reads route through the remote runtime; local project usage/budget reads stay on desktop usage IPC. File actions are strict once a local or remote runtime is bound. During a project switch, preload records a pending local binding for the target root and includes rootPath on local runtime action/sync/event calls so early renderer requests hit the destination runtime project instead of the previous window session binding. During remote project opens, preload clears the current binding, tracks the newest open generation, waits for active remote opens before retrying read-only project calls, blocks mutating action/sync calls with the "Project is switching" error, and avoids refreshing a stale runtime binding. Remote event polling suppresses buffered replay on the first live subscription, resets cursors on eventEpoch changes, notifies project refresh paths on gap: true, and backs off when idle; lane preview URLs returned by a remote runtime are localized through a local TCP forward before the renderer opens them. For a packaged local window temporarily attached to an isolated runtime with sync disabled, only the exact machine-level Sync service is not available / Register a project first failures retry through main-process sync IPC; remote-bound failures never fall back to the local machine.

  • apps/desktop/src/main/services/projects/windowTabRootAuthorization.ts — what a window's set-open-tabs call is allowed to change. resolveWindowTabRoots splits the renderer's one list into two very different things: tabRoots, what the window is displaying, which is 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 runtimeBridge treats as "this window opened this checkout". A path that never opened must not earn local-runtime access merely by appearing in a tab list, so only roots resolving to a project this process actually opened pass through — the same gate projectsForWindowTabs applies on the way back out. main.ts additionally keeps a per-window windowKnownLocalProjectRoots set — every local root the window itself opened this session — surfaced as knownLocalProjectRoots on the window session and folded into runtimeBridge.collectAuthorizedLocalRuntimeRoots. It exists because neither existing signal can answer "was this window ever working in this local checkout?": 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" is exactly that question — its lane lives on this machine no matter which machine the tab is bound to — and rejecting it left cross-machine Work views unable to act on their own local sessions. Membership stays window-scoped and is granted only by the window opening the project itself, never by a renderer-supplied path.

  • Settings > Secrets keeps file ownership explicit across this boundary. The controller desktop opens Finder and reads at most 1 MB from the file the user selected, then sends only its basename and content through the active project_secret.previewEnvImport runtime action. Parsing, replacement detection, and the selected batch import therefore run on the remote project host. project_secret.exportEnv also runs on that host and writes a new ade-secrets.env (or a numbered non-overwriting variant) to that machine's Downloads folder; it never falls back to the controller's Downloads folder while a remote project is bound.

  • apps/ade-cli/src/multiProjectRpcServer.ts — runtime-level project catalog and sync methods, machine-scoped personal-chat methods, plus project-scoped action dispatch. projects.getHandoffStoragePreflight validates a proposed clone parent/path, free-space floor, and destination-local Git access before cross-machine handoff setup mutates the machine. runtimeEvents.* replies include eventEpoch, gap, and oldestCursor from the runtime's bounded event buffer. projects.list inlines host-resolved icons, with dataUrl, sourcePath, and mimeType fields, under a 24-icon / 750 ms connect-path budget with 128 KiB per-icon and 512 KiB aggregate wire caps, so a connected desktop can render real project logos without letting an oversized registry stall connection setup. It also serves machine.updateAndRestart (cto role, runtime endpoint only) and hosts the ProjectlessSyncControls fallback for sync.* on a machine with no project — see sync and multi-device.

  • apps/ade-cli/src/services/runtime/brainHeartbeat.ts — the brain's externally-readable liveness beat: <ADE home>/runtime/heartbeat.json ({pid, ts, seq, startedAt}) rewritten every 15 s from a timer that ticks while the brain is completely idle, which is exactly the state a wedge hides in. A beat that is itself overdue by more than 60 s records suspendGapMs and logs brain.suspend_gap: the writer noticing its own lateness is the cheapest evidence that the machine slept. evaluateBrainHeartbeat is the shared verdict function, and brainWatcherSuspendFloorMs the shared floor both platforms measure a watcher's own lateness against.

  • apps/ade-cli/src/services/runtime/brainWatchdogCheck.tsrunBrainWatchdogCheck, behind ade runtime watchdog-check. Reads the heartbeat and, at most, kills one pid. It deliberately never opens the runtime socket: a wedged brain is precisely the case where connecting hangs, and a watchdog that blocks on its patient is not a watchdog. It keeps one file of its own, <ADE home>/runtime/watchdog-check.json ({ts, staleBeat}), which is how it knows how long ago it last ran and whether it has already seen this exact beat — the two facts behind the machine_slept and stale_unconfirmed verdicts below. The record is written before the judgement, so a check that dies mid-run still leaves its timestamp; an unwritable runtime directory means the watcher stops killing rather than kills on stale information.

  • apps/ade-cli/src/serviceManager/installLaunchdWatchdog.ts — install/uninstall of the com.ade.watchdog launch agent (.beta / .alpha per channel, StartInterval 60 s), done alongside the brain's own agent and best effort: a machine that cannot install the watchdog still gets a brain.

  • apps/ade-cli/src/serviceManager/runtimeServiceBudgets.ts — every timeout in the brain's start/handover lifecycle, in one file, because the numbers only mean anything relative to each other: RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS (30 s) is how long an installer waits before reporting starting, WINDOWS_HANDOVER_TIMEOUT_MS (15 s) the supervisor's shorter equivalent, RUNTIME_SERVICE_YOUNG_BRAIN_MS (120 s) the "still starting, not broken" window shared by the installers' young-brain wait and the desktop's brain_starting diagnosis, RUNTIME_SERVICE_START_WAIT_MS (90 s) a caller's wait for the endpoint, and RUNTIME_SERVICE_STARTING_CONNECT_WAIT_MS (60 s) how long a caller keeps dialling a brain the installer called starting. They were bare literals in seven files, where tuning one silently broke the ordering the whole lifecycle depends on.

  • apps/ade-cli/src/serviceManager/serviceHandover.ts — the handover itself, shared by launchd, systemd and Windows: awaitServiceHandover (responsiveness probe, wait loop, starting verdict) and awaitYoungBrainStart (age check plus the crash-loop veto). Each installer keeps only its own message text, so the three cannot drift.

  • apps/ade-cli/src/services/runtime/awaitRuntimeServiceEndpoint.ts — the install-then-wait policy ade setup uses, free of the CLI's globals so it can be tested against a probe that answers on the Nth call. A failed install with starting set is not a failure: the service is registered and supervised, so it keeps dialling.

  • apps/ade-cli/src/services/runtime/brainStartupState.tsreadBrainStartupState, the CLI's read of the desktop's brain_starting verdict, for callers whose brain did not answer (a brain that answers is running, never starting). The desktop reaches that verdict through its connection pool; the CLI has no pool, so it asks the platform service manager the same questions — is the service registered and running, how old is the brain it supervises, and has that brain been restarted — and calls it starting only when the service is installed, not stopped, its brain is younger than the shared RUNTIME_SERVICE_YOUNG_BRAIN_MS window, and that brain is not crash-looping. The crash-loop veto matters because a brain that dies and relaunches every few seconds is always young, so age alone would report a crash loop as "starting" forever: on macOS and Linux the veto reads the brain's own last-failure.json streak, and on Windows it is the supervisor's restartCount. Windows has no ps -o etime= either, so the age comes from the same supervisor pid record (runtimeStartedAtMs) rather than from the process table. Every probe failure fails closed to not-starting: claiming a brain is starting when we cannot tell would hide a dead one.

  • apps/ade-cli/src/services/runtime/connectWhileServiceStarts.ts — dials the endpoint of a just-installed service, allowing for a starting one, and throws RuntimeServiceStillStartingError rather than a plain connect failure when it runs out of time. That distinction matters: a plain failure is what let a caller fall through to spawning an unmanaged rival brain on a socket the supervisor already owns.

  • apps/ade-cli/src/services/runtime/machineUpdateAndRestart.tscreateMachineUpdateControls / runMachineUpdateAndRestart, the host side of machine.updateAndRestart. Absent for embedded and test runtimes, which have no installed service to update; the method then refuses rather than pretending to have restarted something.

  • apps/ade-cli/src/services/runtime/atomicJson.ts — the shared write-temp-then-rename JSON helper these runtime state files use, so a reader outside the process never sees a half-written record.

  • apps/ade-cli/src/services/projects/ — machine project registry, lazy per-project service scope cache, and projectIconResolver.ts. Brain startup boots only the authoritative sync-host project; other recent projects stay cold until a project-scoped request or explicit handoff needs them, because each scope owns a complete DB/search/chat/automation/PTY runtime rather than lightweight catalog metadata. projectIconResolver.ts (resolveRemoteProjectIcon, an electron-free port of the desktop icon resolver: .ade/ade.yaml override + conventional icon/logo files + index.html <link rel="icon">, best-effort and rendered to a 64 px thumbnail capped at 128 KiB on the wire).

  • apps/ade-cli/scripts/build-static.mjs — produces the static ade-<platform-arch> SEA binary and the .native.tar.gz of native modules, resolves the runtime version from the CLI / desktop package metadata, and verifies same-platform static binaries report that version.

  • apps/ade-cli/scripts/install-runtime.sh and install-runtime.ps1 — standalone installers that download ade-<platform-arch> and the matching native deps from a release. They own only what must happen before the ade binary exists — download, verify, PATH, ade brain start — and hand the rest of onboarding to ade setup, one implementation both platforms share so they cannot drift.

  • apps/desktop/scripts/materialize-runtime-resources.mjs and validate-runtime-resources.mjs — populate and validate apps/desktop/resources/runtime/ for packaging.

User model

A remote target is another ADE machine reachable through a paired sync route, SSH, or both. A paired target stores the machine identity and paired credentials separately from the saved target; the target may also retain SSH user, port, key, and route information for fallback. A remote project is a path on that machine that has been registered with its ADE runtime (via projects.add). Opening a remote project does not copy local files or move a local lane by default. Normal project opening still expects Git to move code between clones; the explicit Send to machine flow adds a guarded clean-lane handoff that publishes the exact source commit, prepares or clones the destination project, creates or reuses the destination lane, and starts a new chat from a bounded portable capsule.

Direct remote targets are account-independent. Trust created through Nearby + PIN or SSH lives on this desktop and continues to connect after sign-out. Account-directory adoption is deliberately account-owned instead: the target and paired DPoP credentials are tagged with the Clerk user id and removed when that user signs out or switches accounts, so a shared ADE install cannot expose another person's machines. Signing in never converts an existing direct pairing into account-owned trust. Changing an SSH host key always requires explicit trust again. Changing the remote host's stable pairing identity, or losing its stored pairing grant, invalidates the saved paired credential and requires re-pairing; ADE does not silently attach old credentials to a different host identity.

The next packaged release intentionally starts this trust model from a clean slate once per channel. It removes previously saved desktop remote targets and paired-device grants, restarts that channel's background runtime, and then lets users pair or adopt machines again. It does not sign the user out, change the machine identity/PIN, remove projects, or touch SSH configuration. Source/dev launches do not perform the reset.

Opening a project on another machine no longer interrupts. The old confirmation dialog existed to warn that a separate remote tab was being created; under one-tab-per-repository there is no second tab, so the warning had nothing left to warn about. Divergence between two checkouts is surfaced where it can actually cost you something instead: at push time, when another machine holds the same branch with unpushed commits (apps/desktop/src/shared/laneDivergence.ts).

One repository, many machines

A repository is one tab. Local and remote checkouts of the same repo — joined on their normalized git origin — collapse into a single tab whose machine is a dimension inside it, switched from a dropdown on the tab. There is no separate "remote" tab, and "remote" is not a machine name: machines are named absolutely ("This computer", "MacBook Pro (97)").

The tab's machine is the global execution context — Lanes, PRs, Files, Git, and Run all follow it. Two things are deliberately wider than that:

  • The Work sidebar is a union. It shows chats in flight on every connected machine for this repository, regardless of which machine the tab is bound to. Lanes not on This computer carry a small monochrome machine marker that promotes to the machine's name when a glyph alone would be ambiguous (the machine is offline, two or more foreign machines are on screen, or the same branch exists elsewhere). Foreign lanes appear only when they have sessions — the union is about work in flight, not an inventory. A machine that goes offline keeps its rows, dimmed, folded shut, and inert, and sinks below the reachable machines. One session renders as exactly one row. The union is built against the ids the active binding's own roster already holds and carries a single claim set across machine slices, so neither a locally seeded optimistic launch nor two slices reporting the same session can produce a second row with its own elapsed clock. Local wins the tie, matching where a click resolves. A slice only claims sessions on lanes that same machine reports, so a session naming a lane a machine does not have cannot suppress the machine that does have it.
  • A session runs on its own lane's machine. Opening a chat, CLI, or shell session from the union streams it from the machine that owns its lane, with its calls pinned to that machine's runtime; the tab stays bound where it was. A row whose owning binding this window does not have open is the exception — there is nothing to pin to, so the tab switches. Clicking a foreign lane (rather than a session) is the explicit move: it switches the tab's machine, the same thing opening a remote project does.

Machine selection also appears at lane creation: the create-lane dialog picks which machine the new worktree is created on, matching each machine's checkout of the repo by git origin. Browsing machines in that dialog and closing it without creating a lane restores the binding the dialog opened on.

Local project opens use typed recovery rather than raw error text. If the machine brain could not open project data, it records a bounded failure report; the local connection pool returns a coded refusal instead of spawning a second brain on the primary socket. The renderer carries that code into the full project recovery surface, where projectRecoveryService can diagnose and repair storage or database state without depending on the failed brain. Remote RPC errors retain their method/code/message/data diagnostics, but they do not run a local repair against data owned by the remote machine. See Storage and recovery.

Connect flow

  1. Open Connections > Machines. When signed in, ADE loads the other computers on the same account. It also combines Bonjour and Tailscale discovery, removes this machine's own Bonjour advertisement, and merges routes that identify the same machine. Discovered paired-capable ADE desktops appear in Available; offline or unsupported machines remain visible in Unavailable.

  2. Select a same-account computer for the primary PIN-less flow. ADE dials the directory-verified Relay first; when the target publishes an ed25519 identity key in its directory row (pubkey), adoption can also fall back to Tailscale and LAN routes using the sealed ade-adopt-v1 handshake — the host signs the client's challenge nonce over an ephemeral X25519 exchange, the client verifies that signature against the directory key before releasing any account credential, and both the account attestation and the returned paired credentials travel AEAD-sealed under a negotiated cipher — ChaCha20-Poly1305, or AES-256-GCM when a packaged Electron's bundled BoringSSL lacks ChaCha20-Poly1305, with the chosen cipher bound into the signed challenge so it cannot be downgraded. A host-identity verification failure aborts adoption immediately (no route is retried); hosts without a published key remain relay-only-adoptable. Successful adoption saves the returned DPoP-bound credentials either way. While connecting, the machine row reports the route being tried, and a failure surfaces inline with a one-tap jump into Nearby + PIN pairing when the same machine is discoverable locally.

    ade-adopt-v1 protects the credentials exchanged during adoption (the account bearer, the DPoP proof, and the minted paired secret are all sealed), not the confidentiality of the ongoing session. After adoption over a plaintext ws:// LAN or tailnet route, the established sync stream has the same on-path exposure as any other direct paired reconnect — an attacker who can already read that LAN can observe the post-adoption traffic, but never the sealed credentials. This matches the pre-existing direct-route trust boundary; relay routes remain trusted-operator plaintext-readable as documented above. Without an account, choose Find nearby computers, select a discovered LAN or Tailscale machine, and enter the six-digit PIN shown on that computer's This computer Connections card. There is no desktop pairing-link paste/scan or manual address + PIN path. A discovered machine with an existing pairing is upgraded to a paired target automatically.

  3. Connect. ADE dials paired routes in LAN → tailnet → relay order, preferring a recently successful endpoint within each class. After authenticated hello_ok, it requires features.rpcChannel === true, opens the runtime JSON-RPC channel, and uses the paired port-forward channel for remote preview URLs. The connected status reports the winning route and latency. A relay route is tried only while this ADE client is signed in. The client attaches a short-lived account proof to that relay hello, and the host accepts it only when the proof belongs to the Clerk user currently signed in on the host. A missing or different account reports Sign in to ADE without spending the automatic-reconnect failure budget. Relay remains a trusted-operator plaintext-readable path, not a confidential channel; end-to-end payload encryption remains planned. A signed-out host does not advertise or hold a Relay tunnel; it resumes automatically after sign-in. Legacy shared bootstrap tokens are rejected over Relay even when they remain valid for an eligible direct reconnect.

  4. If paired dialing fails, or the remote runtime does not support the paired RPC channel, ADE silently falls back to SSH only when that paired target has a route originally configured through Advanced SSH. Nearby/discovery routes are never reinterpreted as SSH fallback. SSH host-key trust is requested only after fallback is actually needed; it is never pre-trusted or prompted before the paired attempt.

  5. Use SSH under Advanced to configure or connect directly: enter a display name, hostname, SSH user, port, and optional private key path. With no key path, ADE uses the local ssh-agent when SSH_AUTH_SOCK is available and matching HostName / IdentityFile entries from ~/.ssh/config. An unknown host key is shown with its fingerprint and requires explicit Trust & connect approval before it is recorded in known_hosts.

  6. On an SSH connection, ADE detects the remote platform with uname -sm and starts ade rpc --stdio. If the bundled runtime is present locally and the remote binary is missing, stale, or hash-mismatched, ADE uploads the binary, native dependencies, PTY worker, and bundled agent skills into the matching ADE channel home, then verifies the runtime. Uploads prefer SFTP and fall back to bounded SSH chunk uploads / OpenSSH. Without a bundled binary, ADE probes alternate channel homes for a compatible installed runtime and reports the selected fallback as a compatibility warning. Windows clients require the built-in OpenSSH Client for the bounded fallback upload path. If ssh.exe is unavailable, ADE reports the missing Windows Optional Feature and the Add-WindowsCapability repair command instead of a raw process-spawn error.

  7. Once SSH RPC succeeds, ADE asks that exact runtime to authorize this desktop as a paired DPoP device using a bounded JSON request on stdin. The resulting secret, private key, host identity, and endpoints are stored locally, and the target is upgraded to paired-first while retaining the verified SSH route as recovery. Older compatible runtimes that do not implement this upgrade remain usable over SSH.

  8. Pick an existing remote project or register a new remote path; the desktop calls projects.add { rootPath } against the remote runtime to bind it. If the same window starts multiple remote opens concurrently, both preload and the main IPC bridge keep only the latest open as the durable binding.

ade code remote consumes that same saved target registry and paired credential store. Before opening a credentialless routed SSH record, it compares the saved name and all saved hosts with the signed-in account directory. An exact, unique legacy account-machine match is adopted into the paired store and the obsolete SSH-shaped record is removed. If the directory cannot be verified, the machine is offline, or adoption fails, that account-created target fails closed; ADE does not silently retry it as SSH. Targets with an explicit SSH user or private key remain true SSH targets and bypass this migration. Interactive launches always show the machine chooser, including when only one target is saved. Paired launches dial LAN → tailnet → relay by default; --route lan|tailscale|relay restricts the attempt to one path class and never falls back to SSH. ADE verifies the long-lived paired connection before it launches the TUI, reuses that connection for the first local bridge socket, and leaves the bridge available for an explicit retry if the remote path later closes. The CLI prints the selected path and reports subsequent path changes without showing temporary socket locations or pairing identifiers.

For a true SSH target, each alternate route is passed as -o HostName=<concrete-route> while the saved hostname stays in the destination argument. OpenSSH therefore still selects the saved Host alias and applies its User, IdentityFile, agent, and proxy configuration, while StrictHostKeyChecking=yes remains enforced. Account resolution, paired LAN/tailnet/relay dialing, and the SSH route × channel-home × binary-command matrix share one 45-second startup deadline. Each child process and paired WebSocket/auth wait is capped by the remaining budget and observes cancellation; authentication failure stops redundant channel-home probes for that route, and the final error aggregates the routes/runtimes that were actually attempted.

After connecting, the desktop persists the active remote project to globalState.lastRemoteProjectBinding and records it in the unified recent-project list with target id, project id, runtime name, and hostname. Remote recents are keyed as remote:<targetId>:<projectId>, so a remote project can share a path string with a local checkout without colliding; the welcome screen can reconnect/open the remote row directly from that metadata. Each target also persists an explicit autoConnect preference: a successful Connect enables it, and Disconnect disables it. Only targets with that preference enabled reconnect at launch or after wake; an explicit failed Connect does not change the saved preference. After 10 implicit failures ADE pauses retries until the user presses Connect, which resets the retry budget without bypassing SSH or pairing authentication.

Per-channel layout: builds with ADE_PACKAGE_CHANNEL=alpha|beta upload to ~/.ade-alpha/ or ~/.ade-beta/ instead of ~/.ade/ so a remote machine can host stable, beta, and alpha runtimes side by side. Runtime binaries, native deps, PTY workers, and bundled ADE agent skills all live under the selected home. Remote compatibility launches keep ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 so remote probes do not fight the user's login service.

A packaged Beta upload that cannot initialize is not terminal by itself. ADE still probes the isolated Stable and other channel homes and accepts the first runtime whose initialization and machine-project capabilities are compatible; the selected home is then used consistently for follow-up commands such as the SSH-to-paired upgrade.

When the connect fails, the user reads one sentence

A failed connect used to hand the user a list of routes. Four lines of lan 192.168.1.24:8787: timeout say nothing about what to do, and three of them are usually irrelevant noise around the one route that actually reached the machine and was rejected.

The main process now classifies the attempts and picks the dominant cause, carried as RemoteRuntimeConnectErrorInfo.failure alongside the existing attempts array. The UI keys its headline and its recovery action off that field, never off the message text. Every host, port, duration and the correlation id stay in a collapsed Details list (ConnectionRouteDetails.tsx), which is where support questions get answered.

Classification is structural. classifyPairedRuntimeFailure (pairedRuntimeRoutes.ts) reads the host's hello_error.code, carried up by PairedRuntimeHelloRejectedError:

hello_error.codeAttempt failure
repair_required, and auth_failed from an older hostpairing
relay_account_requiredauthentication
connection_attempt_supersededsuperseded
invalid_hello, protocol_version_mismatchprotocol

The message-regex classifier is reserved for transport errors, which carry no code at all. Account-machine adoption keeps its own prose classifier (classifyAccountMachineAdoptionFailure in syncPairedMachineStore.ts): that flow has no hello code to read and deliberately orders its rules differently — "pair it again" is a first-class outcome because adoption is the repair, a credential word outranks a transport word, and cipher reads as identity.

superseded exists so a losing route stops reading as a fault. Another route won the same connection attempt; nothing is wrong.

Compatibility warnings

Version skew and capability skew no longer fail the connect outright. The bootstrap performs the JSON-RPC ade/initialize handshake, normalizes the capabilities.machineProjects flags returned by the remote runtime, and reports the result as RemoteRuntimeCapabilities plus a compatibilityWarnings array on the RemoteRuntimeConnectResult. The renderer's remote target panel displays each warning inline under the connection chip. Warnings cover:

  • Runtime version mismatch, shown as a quiet Connections-row note from the two versions (This machine is on ADE X. {name} is on Y. They can still connect — update the older machine when you can.).
  • Remote package channel mismatch (e.g. desktop is beta, remote runtime advertises stable).
  • Missing machineProjects capabilities — browseDirectories, getDetail, getWorkSummary, getDefaultParentDir, create, clone, listMyGitHubRepos. These map to the projects.* RPCs the renderer uses for the project picker / new-project / clone flows. Missing capabilities do not block connect, but the connection pool refuses the matching call with a self-describing error when the renderer attempts it (e.g. Remote ADE service 0.7.2 does not support cloning remote projects.).
  • The bootstrap fell back to a different ADE home (Using remote runtime home .ade-beta because .ade did not contain an ADE service for darwin-arm64.).

Runtime artifact layout

A desktop package carries only its own build target's ade-<platform-arch> binary and matching .native.tar.gz archive in apps/desktop/resources/runtime/, plus the packaged ADE CLI resources that include ptyHostWorker.cjs for remote terminal hosting. Bundling all five targets is what pushed the macOS update ZIP past the 1 GiB Squirrel.Mac cliff (see desktop-auto-update.md), so foreign-platform payloads now fail the build rather than ship.

apps/desktop/scripts/runtime-resource-targets.cjs is the single source of truth for which sidecars a build may contain — darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 — and every packaging stage reads it:

ModeTriggerMeaning
targetADE_RUNTIME_TARGET=<target> (set per release packaging job)Exactly that target must be present and any other target's sidecar is a hard failure.
host-onlyADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1Local channel builds validate only the host target.
fullneitherThe historical local behavior that stages every target.

Only target mode is exclusive, and three stages enforce it: the pre-build CI gate assert-runtime-resource-target.mjs (name comparison only, so a mis-scoped artifact download fails in milliseconds rather than after a ~20 minute build), validate-runtime-resources.mjs before packaging (same diff plus the executable bit and archive-content checks), and the electron-builder afterPack hook.

The consequence for SSH bootstrap is that a desktop no longer has a local copy of every runtime it might need to upload — see "Sidecar fetch fallback" below. win32-x64 is still not an SSH-bootstrap target; a Windows desktop bundles it for its own local brain.

Desktop distributable builds also package apps/desktop/resources/agent-skills/. Remote bootstrap copies that directory into the selected remote ADE home as agent-skills/; the CLI then re-seeds ADE-managed skills into runtime-native home skill directories on launch.

apps/desktop/scripts/validate-runtime-resources.mjs is the preflight that fails the package step when artifacts are missing. Release builds populate the resource directory from the runtime-binary CI workflow's artifacts via materialize-runtime-resources.mjs. For local same-platform packaging, build into the resource directory directly:

npm --prefix apps/ade-cli run build:static -- --target <target> --out-dir ../desktop/resources/runtime

…or set ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1 to validate only the host target during local channel builds (release builds always require the full set).

materialize-runtime-resources.mjs searches ADE_RUNTIME_ARTIFACTS_DIR, then apps/ade-cli/dist-static/, copies any matching artifacts into the resource directory, and falls back to invoking npm run build:static for the host target when a missing artifact is the host build (downloading the official Node SEA helper if ADE_STATIC_NODE_BINARY isn't set and ADE_RUNTIME_DISABLE_NODE_DOWNLOAD isn't 1). It reads the same resolved target set, and in pinned-target mode it also deletes any other target's sidecar it finds — a re-widened CI artifact download cannot leave a foreign payload behind for electron-builder to pick up.

Sidecar fetch fallback

Because a package bundles one target, a mac desktop provisioning a Linux box — or a Windows desktop provisioning a mac — has no local copy of the runtime it must upload. remoteSidecarCache.ts closes that gap by downloading the missing sidecar from the GitHub release, and resolveBootstrapRuntimeSidecars in remoteBootstrap.ts is the one entry point both the POSIX and Windows bootstrap paths call.

Resolution order is bundled → cache → release, and the release tag is this desktop's exact version, never latest, so a remote can never skew ahead of or behind the desktop that installed it. A version that is not a release tag (vX.Y.Z, optionally with a pre-release/build suffix — i.e. a dev build) fails unavailable rather than guessing.

StepBehavior
Cache location<machine ADE dir>/runtime-sidecars/<tag>/<target>/ — channel-scoped like the rest of the ADE home, since a beta desktop uploads beta bytes. Overridable via RemoteSidecarDeps.cacheRoot in tests.
Downloadade-<target>[.exe], ade-<target>.native.tar.gz, and that release's SHA256SUMS, into a .staging-<target>-XXXXXX dir inside the version dir.
VerifyEvery asset is checked against the release's own SHA256SUMS. A missing entry is as fatal as a mismatch.
Publishrename(staging, cacheDir) — same volume by construction. Losing the race to a concurrent bootstrap adopts its (equally verified) copy; a half-written directory from an interrupted run is removed and re-published rather than wedging every future fetch.
GCEvery version directory except the current tag is dropped, best effort, after a hit or a successful fetch.
CleanupThe staging directory is removed in a finally. Non-Windows binaries get chmod 755.

Failures are typed (RemoteSidecarFetchError.kind) and the caller decides whether one is fatal:

kindCauseBootstrap behavior
unavailableHTTP 404/403, or the desktop is not running a published releaseNever fatal — this is the dev-build case; bootstrap continues without an upload.
networkDNS, TLS, timeout, proxy, 5xxSurvivable only when the remote already reports a runtime version; a first-time provisioning that cannot get bytes fails loudly.
checksumDownloaded bytes do not match the published SHA256SUMSAlways fatal. Installing unverified bytes is never the better outcome.

A survivable failure logs remote_runtime.sidecar_fetch_failed and returns no paths. Steady-state reconnects never reach the network at all: when the remote already runs exactly this desktop's version, the fetch is skipped before it starts.

Standalone runtime install

For headless machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. Windows 10 22H2 and Windows 11 x64 machines can install the standalone brain locally or be bootstrapped through Windows OpenSSH Server. Release publishing includes install.sh, install.ps1, SHA256SUMS, the ade-<platform-arch> binaries (with .exe for Windows), and matching native dependency archives. Desktop bootstrap uploads bundled runtime artifacts on first connect, verifies size and SHA-256 through native platform tools, and launches ade rpc --stdio with the channel-specific ADE home. Windows SSH bootstrap requires PowerShell 5.1 or newer and tar.exe; WSL, ARM64, and Windows Server are not supported in Windows v1.

curl -fsSL https://ade-app.dev/install.sh | sh
irm https://ade-app.dev/install.ps1 | iex

These are the promoted URLs; apps/web/api/install.ts serves them by proxying the same release assets, so https://github.com/arul28/ADE/releases/latest/download/install.sh remains equivalent and is what the scripts fall back to for the binaries themselves.

install.sh (lives at apps/ade-cli/scripts/install-runtime.sh):

  • detects platform / arch with uname -sm,
  • downloads ade-<platform-arch>, ade-<platform-arch>.native.tar.gz, and SHA256SUMS from the release,
  • verifies downloaded runtime assets against SHA256SUMS,
  • installs the binary to $ADE_INSTALL_DIR (default $ADE_HOME/bin),
  • extracts the native modules to $ADE_HOME/runtime/<platform-arch>/,
  • verifies with ade --version,
  • best-effort registers the per-user login service via ade brain start on macOS and systemd Linux — not serve --install-service, which inherits an unset ADE_DEFAULT_ROLE, registers the brain at role agent, and makes ade connect fail on every clean install.
  • hands off to ade setup for everything after that: pinned agent CLIs, account, desktop app, end-to-end verification, and a closing summary. The same command re-runs the flow at any time.

Environment overrides:

  • ADE_VERSION=vX.Y.Z — pin a specific release; default latest.
  • ADE_INSTALL_DIR=/custom/bin — destination directory.
  • ADE_RELEASE_REPO=owner/repo — fetch from a fork.
  • ADE_HOME=/path/to/.ade — alternate per-machine state root.

After install, the headless machine can already serve clients. Desktop ADE on a developer laptop adds it as a remote target; ade code works on the headless machine itself.

There are two ways to make that machine reachable, and they are independent:

  • SSH remote target — the desktop bootstraps and tunnels to it. No account involved. Covered by the rest of this document.
  • Account-published machine — run ade connect (or ade connect --headless over SSH, where no browser is available) on the box itself. It signs in, installs the per-user login service, and waits for the machine's row to reach the account directory, after which desktop, the web client, and iOS can all reach it without SSH. The install scripts run this for you as ade setup's account step. No project is required: a headless box with an empty ~/.ade/projects.json publishes itself and dials the relay as soon as its brain holds the machine sync-host lease. See apps/ade-cli/README.md §"ADE account auth" for the three-step contract and why the brain must stay running for the machine to stay published.

Headless hosts update through the same binary, without requiring the desktop app:

ade brain update --text
ade brain update status --text

The update command stages the next release under $ADE_HOME/runtime/updates/, verifies the staged binary with the staged native deps, promotes the binary/native deps into place, and restarts the per-user brain service. A connected mobile or desktop controller should expect the sync/RPC connection to drop briefly while the brain restarts.

What works remotely

Remote project bindings route lanes, agent chat, PTYs, terminal IO, file operations, file-watch notifications, git actions, PR actions, native GitHub stack actions, PR AI conflict-resolution sessions, PR issue-resolution launch flows, AI PR summaries, issue inventory, cross-machine handoff destination checks/acceptance, and event streaming through the remote runtime. The global Chats route deliberately retains the window binding: when opened from a remote-bound project tab, personalChats.call / streamEvents go to that remote machine's hidden personal-chat scope; from a local or no-project window they go to the local brain. Remote lane preview URLs are opened through a local TCP forward created by the desktop, so a dev server bound to 127.0.0.1 on the remote can be inspected from the local window. A connected remote project's tab shows the real project logo and a yellow connected accent: the host brain resolves the icon and inlines it on projects.list, the desktop threads it through RemoteRuntimeProjectRecord.iconDataUrlOpenProjectBinding.iconDataUrl to the tab, and persists it so the logo is restored on a cold start before the remote reconnects. Agent CLI failures (Claude / Codex / Cursor / Droid not installed or not authenticated) surface as inline AgentCliAuthCard cards in chat; the install / login buttons open a tracked terminal in the active runtime, so a remote project runs the install or login command on the remote machine.

Local project bindings use the local ADE runtime for the same surfaces — agent chat, session history, PTYs, terminal reads/writes, file operations and watchers, diffs, lanes, PRs, native GitHub stacks, PR issue-resolution launch flows, PR AI conflict-resolution sessions, issue inventory, tests, project config, and most git operations. Electron main still owns desktop-only services that physically require an Electron host.

Mobile reachability

iOS uses SSH only as an optional one-time pairing bootstrap. Routine mobile traffic always uses the paired sync WebSocket advertised on the LAN, through a Tailscale tailnet, or through the relay. Install Tailscale on the phone and the ADE machine for a direct route when they are not on the same local network.

On desktop, the This computer card and Connections > Phone/Web tabs are runtime controls that always describe the physical computer running this ADE desktop, even while the window is bound to a remote project. Most window.ade.sync.* calls follow the active binding and would report the remote machine, so the Connections panel reads its identity, pairing code, and local device lists through window.ade.sync.getLocalStatus(...), which deliberately bypasses the binding and targets this machine's local brain (see the ade.sync.getLocalStatus IPC handler and useSyncConnections.ts). The binding-routed sync.getStatus is still fetched, but only to detect that the window is remote-bound and to name the machine it is working on. Because the pairing and device mutations (setPin, generatePin, clearPin, forgetDevice, name edits) continue to route through the binding, the panel presents them read-only while remote-bound and labels the connected-device list with the local computer's name so it cannot be mistaken for the bound machine's. The pairing PIN manager lives on the This computer card. The legacy in-process desktop sync host is disabled by default and can be re-enabled only for diagnostics with ADE_ENABLE_DESKTOP_SYNC_HOST=1.

Troubleshooting

  • Remote target was not found — the saved target was removed or the UI has a stale selection. Refresh the target list.
  • Remote machine was manually disconnected. Connect again to use this remote project. — the user explicitly disconnected the target; ADE will not implicitly reconnect or restore it until Connect is pressed.
  • ADE stopped automatic reconnecting after 10 failed attempts. Press Connect to try again. — implicit reconnects were paused after repeated failures so the renderer does not keep hammering SSH.
  • SSH server at <host:port> closed the connection before ADE could finish the SSH handshake — the TCP route opened but the server reset or closed the SSH handshake. Check Remote Login/sshd, firewall rules, and Tailscale SSH policy.
  • ADE service is not installed ... no bundled ADE service is available — install or build ade on the remote, or use a release build that includes runtime resources for the remote architecture.
  • Uploaded ADE service version mismatch: expected X, got Y — the uploaded binary did not report the expected runtime version. Rebuild the static runtime artifacts for the current desktop version.
  • Remote ADE service does not support multi-project mode — the remote is running an older ADE before multi-project RPC. Re-bootstrap from a current desktop build.
  • ADE couldn't connect to this machine. Check that ADE is open there, then press Try again. — ADE could not initialize any compatible installed service. Expand technical details to see each channel home and the launch, initialization, or capability-negotiation failure.
  • Remote ADE service <version> does not support <capability>. — the remote runtime connected but is missing a specific machineProjects capability the renderer just called (e.g. cloning remote projects). Update ADE on that machine.
  • Remote ADE service method <method> failed (code N): <message> Details: ... — the runtime RPC client now surfaces the JSON-RPC error code, message, and data together so a remote handler failure (e.g. a missing project capability or a service action error) is no longer reported as a generic Remote ADE service request failed. string.
  • Remote ADE service timed out waiting for method ... — only that request expired. The shared runtime connection and event subscriptions remain live; retry explicitly if the operation is still needed, because ADE does not replay timed-out mutations automatically.
  • A cross-machine handoff warning that ADE lost confirmation — destination acceptance was already dispatched, but its request timed out or the runtime connection closed. The destination may still finish. Check that machine before retrying; the handoff ID makes an explicit retry reconcile the same destination lane/chat rather than automatically replaying the mutation.
  • "Tailscale discovery timed out / failed" warning under the discovered-machines list — surfaced from discoverLanRuntimes diagnostics. LAN (Bonjour) discovery still ran; unblock tailscale to add tailnet peers. "Tailscale not installed — LAN discovery only." is the info variant of the same diagnostic and renders as a muted note rather than a warning: Tailscale is optional, so a plain Mac without it is not in a degraded state.
  • "Repair" next to the This Mac / route-publish failure — this Mac's brain cannot read the stored account session, so it never publishes to the account directory even though the app is signed in. The button restarts com.ade.runtime and waits for the replacement to answer; the new process re-reads the keychain from scratch. If it reports "Repair failed — quit and reopen ADE", a newer runtime is usually already running and must not be forced down.
  • Agent provider missing or unauthenticated — use the inline AgentCliAuthCard to install or authenticate that provider on the active runtime machine.
  • " says this device's pairing is out of date — pair it again." — the host was reached and it rejected this desktop, so the other routes' timed out/no answer entries under Details are noise. When the machine is also on this ADE account the error card carries a Pair again button, which re-runs account-directory adoption (account.pairMachine) for that machine. Nothing re-pairs on its own.
  • The headline never lists routes. It is one sentence derived from the dominant attempt failure; every host, port, duration and the diagnostic ID stay in the collapsed Details list built from RemoteRuntimeConnectErrorInfo.attempts.
  • Attempt failures are classified from the host's structured hello_error.code, never from its message text — see When the connect fails, the user reads one sentence for the code table. Both repair_required and auth_failed mean the same thing to a paired client, so iOS treats them identically when deciding whether a saved pairing may be dropped — and only ever when the rejecting host's identity matches the saved pairing.
  • wrong machine answered in Details — the address answered as a different host. That endpoint is forgotten rather than demoted: the endpoint list is otherwise append-only (recording a failure re-adds the address, and the recently-failing demotion expires after two minutes), so a neighbour's tailnet name that once landed in the record would be retried forever.

Pairing identity and paired-secret lifetime

A desktop's pairing identity is per-host, not per-machine: sync-device-id is the machine's stable id, but each entry in desktop-paired-machines.json carries its own deviceId that the host uses as the key for its pairing record. Re-pairing the same machine therefore must present the same deviceId — the host upserts on that key. pairWithMachine recovers the prior identity (and its siteId) before it sends the pairing request, because the hello that reports the host identity only arrives afterwards. It looks the saved record up by two things that both identify a host: the caller-supplied hostDeviceId (a QR/link payload and account-directory adoption both carry it), then the relay machine key parsed out of a /connect/<key> endpoint. A bare LAN address is not a host identity — DHCP handing 192.168.1.240 to a different computer would hand that computer the identity this desktop uses with the first one — so matching on a saved endpoint is deliberately not an option, and a LAN pairing with no hostDeviceId mints a fresh identity.

Minting a fresh id when one already exists is not merely untidy: the host keeps the old record forever, secret still valid, with no way to ever match it again, accumulating one orphaned credential per re-pair.

Three logs make a lost pairing diagnosable:

  • Host: sync_host.paired_device_rejected (unknown_device vs secret_mismatch) and sync_host.paired_account_owner_mismatch, both at warn. The host distinguishes those two rejection causes for itself but tells the unauthenticated caller the same thing either way, so the close reason is not an existence oracle for device ids.
  • Host, for peers that never spoke: sync_host.peer_closed_without_frames at debug. The relay readiness self-probe bridges in over loopback and disconnects without sending a frame on every poll, and so does a port scan. Keeping that routine traffic out of sync_host.peer_closed is what makes a rejected peer visible at a glance. Anything that sent at least one frame — including every authentication failure — logs sync_host.peer_closed at info.
  • Desktop: account.local_machines_removed, written at warn to the machine-scoped <machine ade dir>/runtime/account-trust.jsonl (and mirrored at info to the project logger). The machine-scoped sink is the load-bearing one: dropping a paired secret is a machine-level credential mutation, and the project logger follows the active project, which on a remote-bound project ships the record to the other machine and leaves nothing on the machine that actually lost its trust. Each removed credential records its host device id, host name, previous owner, and whether the owner actually changed, so an intended account switch is distinguishable from an identity glitch that silently cost trust. The sink is resolved lazily and never fails account auth.

Relay tunnel and the sync port

The relay tunnel client is cached one per machine, keyed by the cloud-relay config file, and is built by whichever runtime bootstraps first — regularly a scope that owns no shared listener (a headless one-shot, an embedded fallback). So it must not capture per-runtime state at construction. The runtime that owns the listener calls attachHostListener(), which supplies the port, loopback nonce, and bridge proof, registers the onLoopbackValidated retry hook, and validates once the listener is bound. Symptom when this is wrong: routeHealth.listener reports bound and loopback-validated on a real port while relayBridgeValidated is false and lastBridgeValidationAt has never been set — relay silently never works, and a LAN auth failure becomes a total outage because no fallback route exists.

Whether a runtime may dial the relay at all is a separate question, decided by relayTunnelAuthorityGate on the machine-wide sync host lease (syncHostSingleton), not by owning a listener. The relay Durable Object keeps one host control socket per machineKey and evicts the previous holder with close code 4505, so two brains that both dial it evict each other in a tight loop and relay stays down for both — the failure mode that made this a lease in the first place. A 4505 close therefore suppresses redialing (bounded re-attempts on a 60 s floor, then a stop with a 10-minute re-arm) and surfaces as routeHealth.relay.relayControlSuppressed in ade doctor and in the desktop relay banner, rather than being retried as if it were a network fault. The gate rides out the momentary authority gap of an in-process project switch with a 5 s grace and re-attaches the host listener on every start, because stop() drops the listener reference and a start without it would leave a live control socket with no bridge, rejecting every phone connect with "host sync listener unavailable".

ADE advertises its sync port with tailscale serve --bg --tcp=<port>. That outlives the process that registered it, so the served port is reclaimed after each successful publish (staleAdeTailnetServePorts + reclaimStaleTailnetServes). Without it every restart — and every force-kill that skips teardown — orphans an entry that Tailscale keeps bound on the tailnet address; ADE's own next wildcard bind then fails EADDRINUSE against its own leftover and walks one port higher, leaking another. It ratchets forever: one machine reached 66 stranded ports and ~70 failed binds per start, drifting from 8787 to 8852.

Only ADE's exact signature is reclaimed — a port inside ADE's sync range forwarding to 127.0.0.1 on the same port — so a hand-rolled tailscale serve is left alone, and the live port is re-checked inside the loop because reclaiming frees exactly the low ports a restarting host prefers.

Diagnosing this needs netstat -an -p tcp or tailscale serve status, not lsof: tailscaled runs as root, so a user-level probe reports the ports as having no holder, which reads as "free" and is the opposite of the truth.

Wedge supervision and remote repair

A brain can fail in a way no supervisor notices: the event loop dies while the process stays alive. launchd's KeepAlive and the Windows supervisor loop both react only to an exit, so a hung brain keeps its socket, keeps its sync lease, and answers nothing — observed once for 2h14m with zero log output and every remote route dead until someone walked to the machine.

Three layers now cover that, and they are deliberately different in kind:

  • In-processbrainLoopWatchdog runs a worker thread that SIGKILLs the brain when the event loop stalls past its threshold. It cannot help when the worker itself never starts or dies with the process.

  • External — the brain writes <ADE home>/runtime/heartbeat.json ({pid, ts, seq, startedAt}) every 15 s from a timer that ticks while idle (services/runtime/brainHeartbeat.ts). Something outside the process reads it and kills a brain whose beat is older than 90 s and whose pid is still alive. Both conditions are required: an absent or unreadable heartbeat means "no judgement available", never "wedged", and a stale beat with a dead writer is left to the supervisor that already owns the restart.

    Those two facts are necessary and were not sufficient. A laptop that sleeps suspends the brain and its watcher together, so on wake the beat is 40 minutes old and describes the suspension, not a wedge — and the watchdog killed a perfectly healthy brain every time the lid closed. A kill now needs three facts, and evaluateBrainHeartbeat returns a named verdict for each way it can fail to get them:

    1. the beat is stale and its writer is alive and positively identified;
    2. the watcher was awake to watch it go stale — it measures its own lateness, which is the one thing a suspended process can still report. When the gap since its previous run reaches brainWatcherSuspendFloorMs (max(90 s, 3 × the check interval)) and the beat's age fits inside that gap plus the staleness window, the verdict is machine_slept and nothing is killed. A brain that went quiet days before a ten-minute nap is not explained by the nap and still dies;
    3. a previous check already saw this same beat, matched on the beat's own timestamp rather than on its age. A brain the OS merely stopped scheduling beats again before the next check and never reaches a kill; a wedged one is still sitting on the same timestamp a cadence later. Until then the verdict is stale_unconfirmed. A beat that comes back fresh clears the strike, so the two stale checks have to be consecutive.

    Both platforms apply the same three rules. macOS gets them from evaluateBrainHeartbeat; the Windows supervisor implements them inline in PowerShell against the same heartbeat file and the same suspend floor. ADE_BRAIN_HEARTBEAT_STALE_MS overrides the 90 s staleness threshold on both platforms, under the same rules — a positive integer wins, anything else keeps the default, and the result never drops below 30 s — and the suspend floor is computed after the override, so it rescales. Only the pickup differs: the macOS check is a fresh process every 60 s and reads the variable at the next check, while the Windows supervisor reads it once at start, so a changed machine variable applies after the brain service restarts.

    • macOS: a separate launch agent, com.ade.watchdog (.beta/.alpha per channel), StartInterval 60 s, running ade runtime watchdog-check from the same binary the brain was installed from. It is installed and removed alongside the brain's own launch agent, best effort — a machine that cannot install the watchdog still gets a brain.
    • Windows: the PowerShell supervisor already runs a restart loop, so the check folds into it. It waits in 15 s slices instead of one unbounded WaitForExit() and stops a child that stopped beating, applying the same three rules — Get-StaleBeatTs returns the stale beat's timestamp rather than its age precisely so the second-strike rule can recognise the same beat across polls. No Scheduled Task.
  • Desktop, when runningprojectRecoveryService.restartBrain already probes and repairs. The watchdog exists for the headless case, and does not duplicate it.

A kill writes the same event-loop-wedge.json breadcrumb the in-process watchdog writes, so the next start promotes it to last-wedge.json, logs brain.recovered_from_wedge, and it surfaces through the existing lastWedge field on runtime/info with no second reporting path.

The brain's launch agent also pins ThrottleInterval to 10 s. While /Applications/ADE.app is being replaced the brain can be restarted onto half-written files and die instantly (Cannot find module '/serve'); the floor turns that into a slow, self-healing retry instead of a hot spin.

brainFreshnessMonitor handles the other half of a swap: it re-hashes the installed runtime every ~5 minutes and restarts into new code when the hash moves. A file that is missing, unreadable, or still changing is treated as a swap in progress — it must hold still across a settle pause before it is hashed at all — so a mid-copy binary can never trigger a restart into a truncated file.

machine.updateAndRestart

A machine that is stuck, or quietly running old code because no local desktop ever connected to notice, has to be fixable from wherever the user is. The projectless runtime method machine.updateAndRestart does that. It requires the cto role and only exists on the authenticated runtime RPC endpoint, so it inherits the paired/authenticated channel; it is never automatic and never silent.

The client names the version it believes is newest (targetVersion); the host refuses to "update" to the version it is already running and just restarts, which is still the useful action for a wedge-adjacent machine. The reply is step-attributed — check, apply, restart — so a failure names the step instead of saying "something went wrong". The restart step comes back pending on purpose: it tears down the process answering the call, so the client confirms by reconnecting and reading the version. A brain cannot report on its own replacement.

Account state and reachability

Relay gating asks "is this machine still theirs", not "can I call the API". accountAuthService.getStatus() reports a sessionState, and expired (a rejected refresh grant) and unreadable (a credential store that could not be read) are accidents, not decisions: the relay tunnel stays up and the machine keeps its published directory row, so already-paired devices keep connecting while the user signs in again. Only a deliberate signed_out drops the tunnel. The relay control socket authenticates with machine credentials rather than the account token, so this costs nothing in reachability terms. Direct LAN and tailnet routes to a paired device never consulted account state at all.

  • Internal architecture — protocol shape, bootstrap sequence, sync command scoping.
  • ADE CLI — runtime modes, service manager, machine layout, and legacy compatibility command names.
  • ADE Code — terminal client that uses the same runtime.
  • Sync and Multi-Device — phone pairing and multi-device sync (hosted by the same runtime).
  • Cross-machine session handoff — route, repository, Git, capsule, and retry contract for continuing a Work chat on another connected machine.