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 forforgetEndpoint: an address that provably answered as a different host is dropped rather than demoted, becausemarkEndpointFailedre-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.ts—PairedRuntimeHelloRejectedErrorcarries the host's structuredhello_error.codeand 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 theade-<target>sidecar a target-only desktop package does not bundle: version-pinned download,SHA256SUMSverification, atomic publish into<ADE home>/runtime-sidecars/<tag>/<target>/, and typedunavailable/network/checksumfailures. Reached only throughresolveBootstrapRuntimeSidecarsinremoteBootstrap.ts. Shares its asset naming and checksum parsing withapps/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 registersade.runtime.events.release, the renderer's explicit teardown for a subscription it has stopped reading, andade.remoteRuntime.updateAndRestart, the desktop half of the host'smachine.updateAndRestartruntime method (seemachine.updateAndRestart). Two members exist for main-process callers that have no project binding at all:callMachineMethodis 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 allowlistIPC.remoteRuntimeCallSynckeeps for the renderer;isTargetConnected(backed byRemoteConnectionService.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 toservices/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 plusade.runtime.eventpush 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 inpreload.tsshares. 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.tsandapps/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 — alanendpoint per live LAN address (a savedlastHostthat matches the current LAN/Tailscale set is now classified aslan/tailscalerather than the opaquesavedkind, so LAN endpoints publish correctly instead of being dropped), atailnetendpoint per reachable Tailscale address, and arelayendpoint once the tunnel bridge is validated and an end-to-end self-probe confirms the relay path round-trips (relayEndToEndVerifiedAtwith 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 aspubkey, which same-account clients verify before a sealedade-adopt-v1adoption 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.tsandapps/ade-cli/src/bootstrap.ts— the relay side of a paired route. The tunnel client is shared one-per-machine (getSharedSyncTunnelClientService, keyed bysync-cloud-relay.json); bootstrap builds arelayTunnelAuthorityGateper project scope, which starts the tunnel only while this process holds the machine-wide sync host lease and hands the shared listener toattachHostListener()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 thesync_host.paired_device_rejected/sync_host.paired_account_owner_mismatchrejection logs and the frame-count-aware peer-close logs) and tailnet publication, includingstaleAdeTailnetServePorts/reclaimStaleTailnetServes, which retire ADE's own leftovertailscale serveentries after each successful publish. -
apps/ade-cli/src/commands/doctor.ts— theSync portrow names a drifted port and its base-port holders, and says explicitly that a root-owned holder such astailscaledis invisible to this probe rather than reporting the ports as free. -
apps/ade-cli/src/tuiClient/remoteLauncher.ts,pairedRemoteConnector.ts,remoteLaunchBudget.ts, andremoteBridge.ts—ade code remotetarget 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 (minCompatibleProtocolthroughprotocolVersion). A desktop connects normally to a newer machine brain when its ownRUNTIME_COMPAT_LEVELfalls inside that window; a newer incompatible brain remains preserved and the old desktop window falls back to an isolated no-sync runtime. It also spawnsade servefor 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.LocalRuntimeStatusnow also carries the brain'spid, its boundsyncPort, the account-directorypublishHealthslice (state +failingSinceMs+ last-leg durations), and the one-shotlastWedgerecovered by the event-loop watchdog.probeMachineRuntimeIdentity()is the side-effect-free question "what is answering on this machine's endpoint, exactly": aMachineRuntimeIdentitycarrying version, build hash, pid, the build hash this desktop expected, and whether the answering brain is a compatible newer one. It replaces the olderprobeMachineRuntimeHealth(), which answeredokand 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.serviceRepairBackoffMsspaces 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/endUpdateWindowsuppress repair entirely for at mostLOCAL_RUNTIME_UPDATE_WINDOW_MAX_MS(2 minutes) while an update is applying, and connects refused during it getLOCAL_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 throughreadProcessStartTimeMsso 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 readslastWedgefor theBrainRecoveryNoticebanner. Bothserve --install-serviceandserve --uninstall-servicerun through one sharedrunServiceManagerCommandchild-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 atRUNTIME_SERVICE_START_WAIT_MS(90 s) and uninstall at 20 s: a wedged installer used to pinserviceInstallPromiseforever, 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 carriesstartingandrestartedthrough toLocalRuntimeStatus.serviceInstall, and astartinginstall is logged aslocal_runtime.service_install_startingrather than as a success. The status also carriesattemptStartedAt: 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 whatprojectRecoveryService.diagnosemeasures itsbrain_startingwindow against.installServiceBestEffortcoalesces concurrent callers onto one child, but aforceRestart: truecall 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 ownsrestartBrain(), the machine-scoped restart behind the Connections Repair button. Both it andrepair()'s restart_service/verify_endpoint steps go through onerestartServiceAndWait()sequence — install, wait up to 90 s for the machine endpoint to rebind, thenping— 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).forceis 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 arepair()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.sockbefore it starts the mobile sync host (runServeinapps/ade-cli/src/cli.tsstartsrunSyncHostStartupLoopin 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 — waitRUNTIME_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, returnok: true, starting: trueinstead of areplacement_responsivefailure — the supervisor owns that child and it will answer. Every budget in this lifecycle is defined once, inapps/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 inapps/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 areplacement_pidfailure.installSystemd.tsreads unit state from a singlesystemctl --user show -p ActiveState -p MainPIDand treats systemd's ownactivatingas a live brain. The desktop then keeps dialling the socket forLOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS(90 s). A forced install (Repair) that finds an unchanged agent whose child is younger thanRUNTIME_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.diagnosereports the same window asbrain_starting(no Repair offered; the recovery screen re-diagnoses every 2 s and reopens the project itself once healthy), andrepair()streams each step to the window viaIPC.recoveryRepairStepso 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.
adeon Linux (headless brains,install.shinstalls, remote runtimes reached over SSH) goes throughinstallSystemdService, 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 thanRUNTIME_SERVICE_YOUNG_BRAIN_MS(vetoed by a crash-loop streak), andstarting/restarted/failureStepon the way out. -
Windows: "still starting" needs proof of a supervisor. The readiness probe (
serviceManager/windowsSupervisor.ts) returnssupervisedalongsideready, and only sets it once the recorded pid is verifiably our supervisor — alive, and a PowerShell running this launcher. A bareprocess.kill(pid, 0)would not do: the pid comes from an on-disk record, a recycled pid belongs to an unrelated process, andisPidAlivereports EPERM (someone else's pid) as alive. Calling a recycled pid "still starting" turns a genuinely failed install into anok: truethe caller waits on and never repairs. An unanswerable query is likewise neversupervised: 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 theunlink/listenawait, and the loser ends up listening to an inode nothing can reach without ever seeingEADDRINUSE. Losing the inode now ends the brain, the supervisor restarts it, and the restart reportssocket_owned_by_otherinstead of squatting silently. The shutdown that follows removes the socket file only if it still owns that inode (unlinkOwnedRuntimeSocket) — an unconditionalunlinkhere 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 reportsrestarted. 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 recordedAdeRecoveryErrorCodeto 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 fromRemoteRuntimeConnectionAttempt.failure, never re-derived from message text. -
apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx— the error card. Beyond Try again it accepts one optionalaction: a single explicit recovery step for failures retrying cannot fix. Today that is Pair again, offered byRemoteTargetListonly when the rejecting machine is also a row in this ADE account's directory (there is otherwise nothing to re-adopt); pressing it re-runsaccount.pairMachine. Nothing re-pairs on its own. -
apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsxandremoteMachineModel.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 sharedcompareUpdateVersions; a machine level with us gets no button. The click callswindow.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, andblockingHostKeyTrustreports it as its own outcome (needs_trust/changed) rather than throughonError, 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 publishingpublishedstate 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 leastPUBLISH_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 sharedBrainRepairButton/useBrainRepairpair — the same control the Connections This Mac card renders, reading the same publisher health record, with the periodicgetInfopoll extracted as a namedrefreshPublishHealthso a settled restart re-reads it immediately. Discovery diagnostics are rendered by severity rather than lumped together:RemoteRuntimeDiscoveryDiagnostic.severityis"warning"(discovery is degraded and worth looking at) or"info"(a normal, non-actionable fact about the environment). "Tailscale not installed — LAN discovery only." isinfo, because Tailscale is optional and not having it is not a problem;tailscale-timeoutandtailscale-status-failedremainwarning. 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 failedlistDiscoveredMachinescall 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.tsowns the binding-key format the join and every per-project cache are keyed by.TopBar.tsxrenders 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, andmachineDisplayName. 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— readsorigin's URL straight out of the repo's git config (nogitsubprocess per recent) and attaches it to each recent summary, which is the join key the tab grouping above uses.parseGitOriginUrlFromConfig/cleanGitConfigValueundecorate the value the way git does (quoted strings with\escapes, unquoted;/#comment tails),resolveGitConfigDirectorywalks 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.tsand thecrossMachineLanesByMachineId/crossMachineLaneScopeKeyslice ofappStore.ts— the cross-machine Work union. Lanes carry the machine (lanes.worktree_pathis an absolute path on exactly one machine) and chats inherit theirs throughlaneId, 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 becauselane.listwithincludeStatusresolves 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, sincesession.listdoes not filter on lane status whilelane.listexcludes 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 (connectingobserved, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes: every redial publishesconnectingand a single failed liveness ping flips a target toerror, 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 — anidletarget, 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 —repoMatchForwill 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.selectOtherMachineBranchStatesis 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:machineIdis 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.presentLocallyshort-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. Neverstate.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 returnsnullfor 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 warmlaneCacheByProjectlane cache from the surrounding project-scoped store.- Root-store invariant.
crossMachineLanesByMachineIdis written only to the root store — every writer goes throughrootAppStoreApi— andcreateProjectAppStoredoes not copy it, so a project-scopeduseAppStoreread sees an empty record forever. That is why these areuseRootAppStorehooks, and why a React caller must not pass auseAppStorestate intomachineEntryForBinding.
-
apps/desktop/src/renderer/lib/chatMachineRouting.ts— per-session runtime routing.buildLaneBindingIndexfolds each open binding's lane list into a lane→binding index (active binding first wins), andresolveChatRuntimePinreturns theOpenProjectBindinga session's calls must target, ornullwhen it already lives on the active binding.collectOpenProjectBindingsandbuildChatMachineRoutingStateare the shared constructors both consumers use so the two surfaces cannot drift into different definitions of "open" or of lane precedence.isLivePinnedBindingasks 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.tsxandapps/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 aruntimePinprop 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.AgentChatPanerunsuseChatScopeDerivation(session → lane, viauseForeignSessionLaneIdwhen the chat is not in this tab's list → pin, via the chat machine router) and hands the result toChatRuntimeScopeProvider, 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 globaluseAppStoreselector 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 === nullmeans, 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 mountsChatGitToolbarwith its own pin and no provider above it — so the derivation comes from a prop instead of context. Lane rows and worktree paths come fromuseLanesForPin, never from the tab'slanes;onlineis 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, andPushDivergenceDialog.tsx— machine selection during lane creation and the push-time divergence warning.deriveLaneMachineOptionsmatches 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).CreateLaneDialogHostcaptures 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.toMachineBranchStatebuilds its inputs from lane records the renderer already has (LaneSummary.branchRef+LaneStatus.ahead/behindfromLaneListSnapshot/lane_state_snapshots). The rule is grounded inahead, 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 withahead > 0holds 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 (reusingderiveLaneMachineOptions' 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, andprojectRouteStorage.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)plusremoveStoredProjectRoute(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) whileworkViewByProject/laneWorkViewByScopeand 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.
- Explicit tab close, and removing a machine, are deliberate "forget this
surface" actions:
-
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 explicitOpenProjectBindingpin 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/getScreenSnapshotand 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 unpinnedcallProjectRuntimeActionOr. - Pinned event subscriptions.
iosSimulator.onEventandappControl.onEventtake 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.onEventis the deliberate exception: the built-in browser is hosted by this desktop's main process (it owns aWebContentsView) 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 akind: "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 pinnedgetStatus/listDevicesbypasses the cache entirely with a directcallPinnedRuntimeAction. - The bridge object is contract-checked. It is declared
const adeBridge = { … } satisfies Window["ade"]beforecontextBridge.exposeInMainWorld, so a preload signature that drifts from the declaredglobal.d.tscontract — a missingpinparameter 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
rootPathon 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 oneventEpochchanges, notifies project refresh paths ongap: 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-levelSync service is not available/Register a project firstfailures retry through main-process sync IPC; remote-bound failures never fall back to the local machine. - the iOS simulator domain (
-
apps/desktop/src/main/services/projects/windowTabRootAuthorization.ts— what a window's set-open-tabs call is allowed to change.resolveWindowTabRootssplits 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; andauthorizedLocalRoots, the window's local runtime scope, whichruntimeBridgetreats 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 gateprojectsForWindowTabsapplies on the way back out.main.tsadditionally keeps a per-windowwindowKnownLocalProjectRootsset — every local root the window itself opened this session — surfaced asknownLocalProjectRootson the window session and folded intoruntimeBridge.collectAuthorizedLocalRuntimeRoots. It exists because neither existing signal can answer "was this window ever working in this local checkout?":windowProjectTabRootsis replaced wholesale by the renderer on every tab change, andwindowProjectRootsis 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.previewEnvImportruntime action. Parsing, replacement detection, and the selected batch import therefore run on the remote project host.project_secret.exportEnvalso runs on that host and writes a newade-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.getHandoffStoragePreflightvalidates a proposed clone parent/path, free-space floor, and destination-local Git access before cross-machine handoff setup mutates the machine.runtimeEvents.*replies includeeventEpoch,gap, andoldestCursorfrom the runtime's bounded event buffer.projects.listinlines host-resolved icons, withdataUrl,sourcePath, andmimeTypefields, 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 servesmachine.updateAndRestart(cto role, runtime endpoint only) and hosts theProjectlessSyncControlsfallback forsync.*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 recordssuspendGapMsand logsbrain.suspend_gap: the writer noticing its own lateness is the cheapest evidence that the machine slept.evaluateBrainHeartbeatis the shared verdict function, andbrainWatcherSuspendFloorMsthe shared floor both platforms measure a watcher's own lateness against. -
apps/ade-cli/src/services/runtime/brainWatchdogCheck.ts—runBrainWatchdogCheck, behindade 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 themachine_sleptandstale_unconfirmedverdicts 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 thecom.ade.watchdoglaunch agent (.beta/.alphaper channel,StartInterval60 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 reportingstarting,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'sbrain_startingdiagnosis,RUNTIME_SERVICE_START_WAIT_MS(90 s) a caller's wait for the endpoint, andRUNTIME_SERVICE_STARTING_CONNECT_WAIT_MS(60 s) how long a caller keeps dialling a brain the installer calledstarting. 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,startingverdict) andawaitYoungBrainStart(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 policyade setupuses, free of the CLI's globals so it can be tested against a probe that answers on the Nth call. A failed install withstartingset is not a failure: the service is registered and supervised, so it keeps dialling. -
apps/ade-cli/src/services/runtime/brainStartupState.ts—readBrainStartupState, the CLI's read of the desktop'sbrain_startingverdict, 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 itstartingonly when the service is installed, not stopped, its brain is younger than the sharedRUNTIME_SERVICE_YOUNG_BRAIN_MSwindow, 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 ownlast-failure.jsonstreak, and on Windows it is the supervisor'srestartCount. Windows has nops -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 astartingone, and throwsRuntimeServiceStillStartingErrorrather 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.ts—createMachineUpdateControls/runMachineUpdateAndRestart, the host side ofmachine.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, andprojectIconResolver.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.yamloverride + 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 staticade-<platform-arch>SEA binary and the.native.tar.gzof 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.shandinstall-runtime.ps1— standalone installers that downloadade-<platform-arch>and the matching native deps from a release. They own only what must happen before theadebinary exists — download, verify, PATH,ade brain start— and hand the rest of onboarding toade setup, one implementation both platforms share so they cannot drift. -
apps/desktop/scripts/materialize-runtime-resources.mjsandvalidate-runtime-resources.mjs— populate and validateapps/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
-
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.
-
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 sealedade-adopt-v1handshake — 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-v1protects 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 plaintextws://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. -
Connect. ADE dials paired routes in LAN → tailnet → relay order, preferring a recently successful endpoint within each class. After authenticated
hello_ok, it requiresfeatures.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. -
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.
-
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_SOCKis available and matchingHostName/IdentityFileentries from~/.ssh/config. An unknown host key is shown with its fingerprint and requires explicit Trust & connect approval before it is recorded inknown_hosts. -
On an SSH connection, ADE detects the remote platform with
uname -smand startsade 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. Ifssh.exeis unavailable, ADE reports the missing Windows Optional Feature and theAdd-WindowsCapabilityrepair command instead of a raw process-spawn error. -
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.
-
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.code | Attempt failure |
|---|---|
repair_required, and auth_failed from an older host | pairing |
relay_account_required | authentication |
connection_attempt_superseded | superseded |
invalid_hello, protocol_version_mismatch | protocol |
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 advertisesstable). - Missing
machineProjectscapabilities —browseDirectories,getDetail,getWorkSummary,getDefaultParentDir,create,clone,listMyGitHubRepos. These map to theprojects.*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:
| Mode | Trigger | Meaning |
|---|---|---|
target | ADE_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-only | ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1 | Local channel builds validate only the host target. |
full | neither | The 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.
| Step | Behavior |
|---|---|
| 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. |
| Download | ade-<target>[.exe], ade-<target>.native.tar.gz, and that release's SHA256SUMS, into a .staging-<target>-XXXXXX dir inside the version dir. |
| Verify | Every asset is checked against the release's own SHA256SUMS. A missing entry is as fatal as a mismatch. |
| Publish | rename(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. |
| GC | Every version directory except the current tag is dropped, best effort, after a hit or a successful fetch. |
| Cleanup | The 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:
kind | Cause | Bootstrap behavior |
|---|---|---|
unavailable | HTTP 404/403, or the desktop is not running a published release | Never fatal — this is the dev-build case; bootstrap continues without an upload. |
network | DNS, TLS, timeout, proxy, 5xx | Survivable only when the remote already reports a runtime version; a first-time provisioning that cannot get bytes fails loudly. |
checksum | Downloaded bytes do not match the published SHA256SUMS | Always 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, andSHA256SUMSfrom 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 starton macOS and systemd Linux — notserve --install-service, which inherits an unsetADE_DEFAULT_ROLE, registers the brain at roleagent, and makesade connectfail on every clean install. - hands off to
ade setupfor 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; defaultlatest.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(orade connect --headlessover 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 asade setup's account step. No project is required: a headless box with an empty~/.ade/projects.jsonpublishes itself and dials the relay as soon as its brain holds the machine sync-host lease. Seeapps/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.iconDataUrl → OpenProjectBinding.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 buildadeon 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 specificmachineProjectscapability 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 errorcode,message, anddatatogether so a remote handler failure (e.g. a missing project capability or a service action error) is no longer reported as a genericRemote 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
discoverLanRuntimesdiagnostics. LAN (Bonjour) discovery still ran; unblocktailscaleto add tailnet peers. "Tailscale not installed — LAN discovery only." is theinfovariant 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.runtimeand 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
AgentCliAuthCardto 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 answerentries 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. Bothrepair_requiredandauth_failedmean 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 answeredin 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_devicevssecret_mismatch) andsync_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_framesat 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 ofsync_host.peer_closedis what makes a rejected peer visible at a glance. Anything that sent at least one frame — including every authentication failure — logssync_host.peer_closedat 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-process —
brainLoopWatchdogruns 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
evaluateBrainHeartbeatreturns a named verdict for each way it can fail to get them:- the beat is stale and its writer is alive and positively identified;
- 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 ismachine_sleptand nothing is killed. A brain that went quiet days before a ten-minute nap is not explained by the nap and still dies; - 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_MSoverrides 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/.alphaper channel),StartInterval60 s, runningade runtime watchdog-checkfrom 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-StaleBeatTsreturns 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 running —
projectRecoveryService.restartBrainalready 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.
Related docs
- 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.