Sync and Multi-Device
August 30, 2026 · View on GitHub
ADE syncs live runtime state across an ADE machine runtime and any connected controllers (other computers, iPhones) using cr-sqlite as a CRDT-backed replication layer over a WebSocket transport. The design is local-first: eligible routes are preferred in LAN → Tailscale → Relay order for desktop-to-runtime, ADE Code, and iOS connections. The account-gated cloud tunnel relay is an always-eligible byte transport whenever the machine is signed in to an ADE account; there is no separate relay toggle. Two machines on the same LAN (or Tailscale tailnet) converge their application state directly.
This README covers the sync model, the runtime/controller role split, what does and does not travel, and the layers that implement it. Deep-dives:
crdt-model.md— cr-sqlite CRR retrofit, schema implications, merge semantics, and the iOS pure-SQL emulation layer.ios-companion.md— the iPhone controller path: SwiftUI app, native SQLite, pairing, tab structure, command routing from phone to runtime.../web-client/README.md— the hosted browser controller path: static Cloudflare Pages SPA, account sign-in and directory adoption, WebCrypto DPoP, browser-safe sync transports, no local DB, and thewindow.adeadapter over remote commands.remote-commands.md— thesyncRemoteCommandServiceregistry that turns client actions into runtime-executed mutations.cross-machine-session-handoff.md— the clean/published Git contract, bounded context capsule, destination setup, route confirmation, and idempotent recovery used by Send to machine.push-notifications.md— Activity's account-wide source of truth and its APNs + Live Activity pipeline: machine publishers, the Cloudflare consolidation relay, desktop/web/ADE Code/iOS reads, native Mac presentation, per-device policy, exact routing, acknowledgments, and ownership fences.
Web client: the browser client is another controller of the same machine runtime. New hosted connections are account-only: the browser signs in, chooses a same-account machine from the directory, and receives DPoP-bound paired credentials through Relay. It does not keep a local SQLite replica; it uses changeset batches as invalidation signals and refreshes state through remote commands, file requests, and chat/terminal streams. Browser environments paired before this release can still reconnect over their saved local/direct routes, but the hosted client no longer creates non-account pairings.
Account Activity deliberately does not follow that selected machine or project binding. Every signed-in brain publishes all of its active projects to the account relay, while signed-in desktop, hosted web, ADE Code, and iOS read the consolidated account stream through an account-scoped path. Navigation and actions still carry the owning machine/project/session so the client can adopt or select the correct destination. Without an account, a client may show only a truthfully labeled local or explicitly connected-machine snapshot; it must never present that subset as the account view.
Where the sync authority runs
The sync authority is the machine-owned ade serve runtime in
apps/ade-cli/. The desktop renderer is just another client of that
runtime — it attaches through the local runtime connection pool, exactly
the same way ade code and the iOS app do.
This is the inversion to internalise: the desktop is no longer the
sync authority. A desktop window that is bound to a remote runtime is therefore
not the authority either; the remote ade serve on that machine owns the
authority role for projects opened on it.
The legacy in-process desktop sync host still exists in source for
diagnostics. It is disabled by default and only activates when
ADE_ENABLE_DESKTOP_SYNC_HOST=1 is set (and the kill-switch
ADE_DISABLE_SYNC_HOST=1 is not set). Production builds and dev
sessions both leave it off; everything below describes the runtime-hosted
path unless explicitly noted.
Windows x64 can own the same machine sync authority as macOS/Linux. It uses a
per-user/channel named pipe for local RPC and the packaged
vendor/crsqlite/win32-x64/crsqlite.dll for CRR replication. Linux x64/arm64
vendor crsqlite.so in the same layout and ship it in the standalone native
tarball so install.sh brains host CRR like any other peer. The typed
crdtSyncAvailable status prevents pairing when the extension is unavailable;
Connections surfaces the failure with reinstall/restart guidance. Native
macOS computer use and iOS Simulator are separate capabilities and do not gate
Windows phone pairing, App Control, browser control, or proof ingestion.
The machine-wide sync host lease
Hosting phone sync is exclusive per machine, and that exclusivity is a real
lease, not a convention. syncHostSingleton.ts owns an advisory lock file at
$TMPDIR/ade-sync-host-<uid>.json (override: ADE_SYNC_HOST_LOCK_PATH)
recording the owning pid, channel, project root, and bound port. A project
scope acquires it when its sync service starts; a projectless brain that binds
the shared listener with no active scope acquires a projectRoot: null lease
of its own and drops it the moment a scope takes over, so the lock file always
names the real owner.
The lease is also the answer to "is it me?" for every other machine-exclusive subsystem. Two of them gate on holding it:
- the relay tunnel (
syncTunnelClientService), because the relay Durable Object keeps exactly one host control socket permachineKeyand evicts the previous holder with close code4505; and - the account-directory publisher (
accountMachinePublisherService), because publishing endpoints means "reach me here", and a runtime that does not host sync would be pointing controllers at nothing.
Holding a listener is not sufficient for either — a dev ade serve, a
headless one-shot, or an embedded fallback can bind an ephemeral listener
without ever winning the lease. holdsSyncHostSingleton() reports current
process authority and onSyncHostSingletonAuthorityChanged() publishes
transitions (none-held → held and held → none-held only), because the lease is
acquired well after process start and can be released again.
Authority transitions are debounced by
SYNC_HOST_AUTHORITY_RELEASE_GRACE_MS (5 s) on the loss edge.
ProjectScopeRegistry.performSyncHostSwitch deactivates the outgoing sync host
before activating the target, so within one brain authority legitimately reads
false for the width of a project switch. A real loss of authority outlives that
window; a handoff never does. Without the grace, every project switch would
stop and restart the machine's relay tunnel and tear down and rebuild the
directory publisher (ade doctor would report "Account-directory publishing
has not started" in the gap).
Hosting sync, and publishing, with no project
A project is not a precondition for anything machine-level. A brain that holds
the lease and has the shared listener bound hosts phone sync, publishes itself
to the account directory, and dials the relay with nothing in
~/.ade/projects.json. That is the normal state of a headless box and of any
machine between the installer finishing and the user opening their first
repository.
projectlessSyncSnapshot.ts builds the SyncRoleSnapshot for that state.
Hosting is the lease and a bound port: a runtime that bound a listener
without winning the lease is not the machine's sync host and reports the honest
all-down shape (buildDegradedProjectlessSyncSnapshot) instead of claiming a
host that does not exist. When it is hosting, the snapshot carries the real
listener port, the machine identity read from ~/.ade/secrets/sync-device-id /
sync-site-id, real pairingConnectInfo, and real relay route health.
Those last two fields are why this matters beyond diagnostics. The
account-directory publisher gates on listenerBound and pairingConnectInfo,
so a projectless brain fed a hardcoded all-down placeholder could never publish
itself — a signed-in machine with an empty project registry simply never
appeared in the user's account, and ade doctor reported it unreachable while
it was serving phones fine. runServe's publisher getSnapshot now falls back
to the projectless builder whenever this brain holds the lease, and returns null
only when it genuinely does not.
Two consequences follow for copy anywhere in the product:
- "Open a project" is never the fix for an unpublished machine. It was
before; it is not now. The
no_active_sync_scopepublisher state is now only reachable when another ADE process on this computer holds the machine-wide lease — and that process is the one publishing the machine. Telling the user to open a project would hand them an action that cannot change anything. The per-state advice lives in one table,describeUnpublishedAccountDirectoryinapps/desktop/src/shared/types/sync.ts. - A projectless brain still has no project-scoped state to report. The
runtime name and Tailscale Serve publication belong to a project scope, so the
snapshot reports them absent rather than inventing them (the tailnet row reads
"Open a project to publish this machine on your tailnet."). The pairing PIN is
not in that category: it is the only nearby-pairing path a projectless
machine has, so the snapshot reports its real state from the same
machine-level store the ingress handler verifies against, and the plaintext
PIN plus the machine bootstrap token are exposed under the same
hostinggate the scoped host applies. Hardcoding "no PIN" here is what made a fresh install look unpairable even after one had been set. Publication to the account directory still does not require a pairing PIN: account membership is the auth path, and the PIN is the fallback for nearby devices that are not signed in.
The relay half is symmetric. createAdeRuntime builds the relay tunnel per
project scope, so with no scope nothing dialed it and such a machine was
LAN-only. machineRelayTunnel.ts is now the one construction both paths use;
runServe builds it lazily on the same event that takes the projectless lease.
Relay is an extra route, never a precondition for hosting sync, so a failure to
build it is logged (sync.projectless_relay_start_failed) and startup
continues.
One ingress implementation, two ingress paths
A projectless machine and a project-scoped machine accept the same clients, so the handshake they run is one implementation with options, not two similar ones. Two shared modules replaced the copies that used to drift within a release:
syncHelloProtocol.ts— the one parser forhelloandpairing_request. The brain's fallback handler previously carried a narrower hand-rolled copy that understood onlybootstrapandpairedauth, so every newer auth shape (the web client'saccounthello, the desktop'saccount_sealedadoption) was answered with a flat "Invalid hello payload." on exactly the machine those clients are meant to reach. The copy also droppedconnectionAttempt, leaving route arbitration blind on that path.syncAccountHelloAuth.ts— the account-hello gate chain (attestation, same-account check, device-key validity, pairing mint/rotate, commit-lock discipline) plus the canonical rejection messages. The real differences are options: the brain has no connection arbitration and no sealed adoption, so it leaves those options unset. Everything else — gate order, strings, codes — is shared, which is what stopped the two ingresses from returning different codes for the same cause.
brainMachineSyncStores.ts is the matching state half: the machine-level PIN,
pairing, and security stores, created once per secrets directory and handed to
every surface. Pairing identity belongs to the machine, not the caller, so the
ingress handler and the RPC surface must hold the same instances — a PIN
generated over RPC is only live immediately if the socket that verifies it reads
the same in-memory store. The map key is pathKey(resolvedDir) so a Windows
path spelled C:\Users\… and \\?\C:\Users\… cannot hand out two store sets
for one directory.
sync.* on a machine with no project
Every sync.* RPC method used to resolve a project-scoped sync service and fail
with "Sync service is not available. Register a project first." A machine that
had never opened a project therefore could not set its pairing PIN — so it could
not be paired at all, despite hosting sync perfectly well. ade sync pin generate and the desktop's ade.sync.setPin both hit that wall.
multiProjectRpcServer.ts now routes those methods through withSyncService:
the project-scoped service when one owns sync, otherwise the
ProjectlessSyncControls the brain injects (createProjectlessSyncControls,
backed by brainMachineSyncStores). A handler built without controls (tests,
embedded runtimes) still fails loudly with the old error rather than pretending
to host.
Only the methods a bare machine can honestly answer have a fallback:
sync.getPin / setPin / generatePin / clearPin, sync.forgetDevice,
sync.getRequireDpop / setRequireDpop, and sync.getCloudRelayStatus. The
rest answer with the honest empty shape rather than an error:
sync.refreshDiscovery returns the machine snapshot as-is (there is no
project-published discovery to re-run), sync.listDevices returns an empty list
(the machine pairing file is keyed by device id with no listing API, and the
runtime state that fills that list is a project's device registry), and
sync.getRuntimeName returns null because a runtime name is a per-project
setting. Mutating project-scoped methods (sync.setRuntimeName,
sync.updateLocalDevice, transfer readiness, lane presence) keep failing with
the register-a-project error, which is the truth for them. A
just-written PIN is echoed back on the returned snapshot by
machineSyncStatusWithPin, because a store only reports a plaintext code it set
itself and the snapshot builder may read through a different handle — without
it, setting a PIN returned a snapshot claiming there wasn't one.
Relay status is likewise one projection: buildSyncCloudRelayStatus in
syncCloudRelayStatus.ts, used by both the scoped and machine paths so the
desktop and the CLI cannot tell two different relay stories about one machine.
Its accountSignedIn gate is ownership, not usability — see
remote runtime → Account state and reachability.
Who participates
- Machine runtime — the per-channel, per-machine
ade serveruntime. It owns agent execution, PTYs, worktrees, worker heartbeats, the orchestrator, and the sync WebSocket server. It can hold multiple open projects at once behind a single brain-level WebSocket listener on a stable port; a phone picks which project to bind to via the machine project catalog, and when the hosted project changes the new project's host service adopts the open sockets instead of dropping them. - Desktop renderer — a client of the local runtime over the runtime IPC bridge. The same renderer can also bind to a remote runtime (the remote-runtime feature), in which case sync state lives on the remote machine.
- iOS app — client/controller-only, always. Connects to a runtime over
WebSocket using the same
SyncEnvelopeprotocol the desktop uses internally. - Browser web client — client/controller-only, hosted static SPA
(
device_type: "browser"). Adopts a same-account machine through the directory and Relay, then reconnects with a per-device secret + WebCrypto DPoP. It keeps no local SQLite replica: it treats changeset batches as invalidation signals and reads through remote commands, file requests, and chat/terminal streams. Its workspace Hub may retain catalogs and open project bindings for many machines, but a four-client pool bounds live WebSockets; least-recently-used non-active machines become Parked and reconnect on demand. Saved pre-release local pairings remain a reconnect compatibility path. See../web-client/README.md. - Cluster state — a singleton
sync_cluster_staterow with the legacy columnsbrain_device_idandbrain_epochtracks which device currently owns execution within a cluster.
The older terms "brain" and "host" still appear in code, schema, and protocol types. In the current product vocabulary, they refer to the same thing: the runtime that is the current sync authority.
Connection route policy
Every native paired controller ranks routes the same way:
- current LAN endpoints;
- current Tailscale/tailnet endpoints;
- ADE Relay, only with a fresh matching account proof.
Within the LAN and tailnet tiers, current discovery outranks stale saved
metadata and recent successful endpoints break ties. ADE Code and the desktop
paired-runtime pool walk that ranking as sequential phases through
buildPairedEndpointCandidates. iOS races the whole ranked plan at once
instead: the same preference decides who is dialed first, but a Relay candidate
joins the race a few hundred milliseconds behind the leading direct candidate
rather than waiting for every direct route to exhaust, and is dialed
immediately when it leads the ranking — which it does when it is the proven
route for the network the phone is on. iOS also keeps per-network route memory
and per-endpoint failure memory; see Route ranking, route memory, and roaming
in ios-companion.md. First-time same-account adoption also
uses LAN → tailnet → Relay when the directory row contains a verifiable host
signing key, because the ade-adopt-v1 sealed challenge protects the account
credential on direct routes. An unsigned legacy directory host is Relay-only;
ADE never sends a plaintext account bearer to an unverified LAN/tailnet peer.
The hosted HTTPS web client applies the same ranking to routes the browser may
legally dial, but browsers cannot open insecure ws:// LAN/Tailscale sockets
from https://app.ade-app.dev. In production that eligibility filter normally
leaves Relay only; local HTTP development and previously verified secure
direct endpoints can exercise the direct phases.
Successful and failed native paired-runtime connects retain one random
correlation id plus at most eight privacy-safe ordered attempts. An attempt
contains route kind, host + optional port, start time, duration, outcome, and a
coarse failure class; paths, query strings, tokens, pairing secrets, and raw
error text are excluded. The same correlation id is forwarded on Relay dials
and appears in tunnel lifecycle logs. Account-directory list/register/delete
requests send X-ADE-Correlation-ID; the Worker validates or replaces it,
returns it on every response, exposes it to the trusted web origin, and writes
one structured completion record with route, method, status class, and
duration.
Mobile compatibility contract
Mobile clients must be able to connect to older and newer ADE brains long enough to show update state and invoke supported commands. The sync hello is therefore additive: new host features are optional, and a missing feature puts the phone in a limited mode instead of failing the WebSocket connection.
The shared contract lives in
apps/desktop/src/shared/syncMobileCompatibility.ts. The brain advertises
features.mobileCompatibility with a contract version, required mobile command
actions, and any missing actions. iOS treats an omitted feature as a legacy
limited host, preserves the connection, gates unsupported remote actions before
queueing/sending them, and shows update guidance from the host state. When a new
mobile release adds required host behavior, update the shared contract and the
iOS compatibility tests in the same branch.
Alongside the required set the file keeps an optional list
(MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS) for additive commands newer
phones feature-detect but that older mobile builds never call — so their absence
must not put a host in limited. The four Linear connection commands
(cto.startLinearMobileOAuth, cto.completeLinearMobileOAuth,
cto.setLinearToken, cto.clearLinearToken) that let the phone connect,
reconnect, and disconnect Linear are optional: a brain that predates them simply
doesn't advertise them, and the iOS Linear pane hides those affordances locally
instead of erroring. cto.getAttention — the read-only probe behind the phone's
CTO tab badge, needed because the CTO chat is excluded from every session roster
and cannot be derived from the chat list — is optional on the same logic: the
phone feature-detects it and otherwise leaves the badge dark, and requiring it
would flip every already-shipped brain into limited mode. The
session-lifecycle commands
(session.settleSessions, session.unsettleSessions,
session.setSettleOverride, session.snoozeSession, session.wakeSession,
session.clearWokeMarker) are optional for the same reason: the phone
feature-detects them before showing settle and snooze controls, while an older
mobile build that never calls them must not push a newer host into limited.
chat.regenerateSessionMetadata is also optional: newer controllers may
feature-detect the one-call chat metadata refresh, while older phones never
invoke it and therefore remain compatible.
See remote-commands.md and ../linear-integration/README.md.
What syncs, what does not
| Data category | Sync mechanism | Devices |
|---|---|---|
Replicated ADE runtime tables in .ade/ade.db | cr-sqlite CRRs over WebSocket | All connected devices |
| Source code files | git push/git pull | Desktop peers only |
Shared ADE scaffold/config (.ade/.gitignore, .ade/ade.yaml, human-authored templates/skills, repo-backed workflow YAML under .ade/workflows/linear/**) | Git | Desktop peers only |
Local overrides (.ade/local.yaml, .ade/local.secret.yaml) | Never syncs | Machine-specific |
| Worktrees, PTY processes, caches, transcripts, artifacts, sockets, secrets, connection drafts | Never syncs | Machine-specific |
Product-analytics installation IDs, consent, budgets/deduplication state, and the local usage_events export ledger | Never syncs | Machine/browser/iOS-client specific; paired-client consent is socket-scoped |
PR detail bodies (pull_request_snapshots: files_json, comments_json, reviews_json) | Desktop peers replicate them; phones fetch them on demand via prs.refresh instead | Desktop peers only |
| Cross-machine Work chat continuation | Explicit Git publication + bounded handoff capsule over a connected machine runtime; not CRDT replication | Connected ADE desktops |
| Personal chat summaries/transcripts/attachments | Runtime commands + chatScope: "personal" transcript stream; not active-project CRR changesets | Controllers connected to the owning machine brain |
Two devices in the same cluster do not have identical .ade/
folders. Git gives them the same tracked scaffold; sync gives them the
same replicated DB state; each device still has its own local runtime
directories.
Two disconnected desktops do not have a shared live session. They converge code through Git and they converge the narrow tracked ADE scaffold through Git, but live chat/process state converges only when they join the same sync cluster (i.e. point at the same running sync authority).
Host-authoritative columns are peer-scoped
Replication is not only a question of which tables cross the boundary. A few columns on tables that do replicate are decisions only the host can make, and the host refuses to let a controller author them.
The current set is terminal_sessions.settled_at, settle_override, and
settle_source (HOST_AUTHORITATIVE_COLUMNS_BY_TABLE in syncHostService.ts).
A settle is decided by sessionService, the only place that can weigh it
against live work. Because the table replicates and cr-sqlite merges
last-writer-wins per column, a controller that writes its own optimistic
settled_at sends a value carrying no host lifecycle revision — and it merges
in regardless of what the host decided, so a host that rejected the settle
still ends up with a settled row. That is a guard defeated by a merge rather
than by a caller, which no amount of host-side checking closes.
The filter drops those columns from inbound changesets from phone peers
only. Two properties make it different from the table-level
SYNC_HOST_AUTHORITATIVE_TABLES rule:
- It is peer-scoped, and deliberately so. A paired desktop runs the same
sessionServicechokepoint, so its settle writes are host-decided too and must keep replicating; broadening the filter would silently stop settle propagating between two of one user's machines. Those writes are not applied blind, though:applyChangesreports settle-tuple columns whose value actually moved, and the receiving host re-asserts them through its own chokepoint so they gain its lifecycle revision and abort an in-flight settle rather than overwriting it. See settle-teardown-design.md §6d. - A paired phone cannot opt out of it.
isMobilePeerresolves a record-backed peer through its pairing record — host-side truth — and falls back to the peer's ownhellometadata only when the auth kind is not record-backed. DeclaringdeviceType: "desktop"therefore does not evade the filter. It remains a compatibility guard rather than a hard boundary, because a bootstrap-token peer is still classified from what it says about itself; the complete closure is the host-local lifecycle revision in step 1 of the settle-teardown design. Current iOS never writes these columns at all — it shows an in-flight settle through a local overlay instead (PendingSessionSettleStates.swift).
The drop is silent and per-column: the rest of the batch applies, including the
phone's own snooze overlay (snoozed_until / snoozed_at / woke_*), which
the phone legitimately owns because no host decision rides on it. The batch
still acks ok — a rejecting ack would stall the peer's outbound cursor and
make it resend the same range forever. A pre-fix phone's dropped value is
therefore local-only divergence, never host corruption, and it heals when
refreshWorkSessions next rewrites the row from the host's work.listSessions
payload — a full replace within the active project's lanes, not a per-row merge,
so the host's value always wins. Rows in a project the phone has not activated
stay stale until it is.
See terminals and sessions for the lifecycle side of this invariant.
Architecture layers
┌──────────────────────────────────────────────────────────────────┐
│ Renderer (Electron) / iOS SwiftUI │
│ - reads local SQLite (instant, offline) │
│ - writes: state-only → local; execution → remote command │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Desktop runtime IPC bridge (renderer → main → runtime) │
│ - sync.* preload calls route through │
│ callProjectRuntimeSyncOr(method, params, fallback) │
│ - prefers the remote runtime if the window is bound, │
│ otherwise the local runtime │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ ade-cli machine runtime (`ade serve`) │
│ - syncService — orchestrator, draft persistence, pin store │
│ - syncHostService — WebSocket server, peers, project catalog │
│ - syncRemoteCommandService — registry of executable actions │
│ - deviceRegistryService — devices + cluster_state singleton │
│ - hosts MULTIPLE projects per machine │
│ - one brain-level shared listener (sharedSyncListener); │
│ per-project host services adopt peers across switches │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Sync transport (ws) │
│ - SyncEnvelope: hello, pairing, changeset_batch, │
│ changeset_ack, heartbeat, file_request/response, │
│ terminal_*, chat_*, brain_status (legacy name), │
│ project_catalog/project_switch/project actions, │
│ command / command_ack / command_result, │
│ envelope_chunk │
│ - negotiated deflate above 512 B; no offer keeps the exact │
│ legacy encoder (gzip at 4 KB, or web JSON) │
│ - compressed payloads ride a binary frame ("ADE1" magic, u32 │
│ header length, header JSON, raw bytes) for peers declaring │
│ "binaryEnvelopes"; everyone else keeps base64-in-JSON │
│ - permessage-deflate on both listeners; when a peer negotiates │
│ it the application codec is skipped (stacking measures worse │
│ than either layer alone) │
│ - decode capped at 25 MB; reassembly capped and expires at 30 s│
│ - encoded envelopes >720 KB sliced bidirectionally after the │
│ host confirms the peer's "chunkedEnvelopes" capability; │
│ binary peers slice into binary chunks (no base64 re-tax) │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ cr-sqlite CRDT layer │
│ - desktop/runtime: loadable .dylib extension, crsql_as_crr() │
│ - iOS: pure-SQL emulation in Database.swift │
│ - AdeDb.sync: getSiteId, getDbVersion, │
│ exportChangesSince, applyChanges │
└──────────────────────────────────────────────────────────────────┘
Source file map
The canonical sync implementation lives in the ade-cli runtime package. The desktop tree only contains thin re-export proxies plus the legacy fallback; do not edit the desktop copies expecting the runtime to see your change.
Runtime support files outside services/sync/:
-
apps/desktop/src/main/services/state/crsqliteExtension.tsandapps/ade-cli/scripts/package-native-deps.mjs— loadable cr-sqlite for every published runtime (darwin-*,linux-*,win32-x64). The packager copies the matching payload into the standalone native tarball soinstall.shbrains resolve it from$ADE_HOME/runtime/<target>/vendor/crsqlite/. -
apps/ade-cli/src/services/account/accountAuthService.tsandaccountAttestationVerifier.ts— the shared desktop/runtime Clerk session and Relay-token verifier. Access tokens are used only while their signed expiry is current; callers can force one refresh after an authenticated service returns 401. Refresh-token rotation is cross-process safe: aninvalid_grantbriefly waits for a desktop/runtime peer to publish its rotated replacement, and stale credentials are compare-and-deleted only when the store can prove that no peer replaced them. A successful rotation is persisted before best-effort userinfo enrichment, and subject changes across the old token, new token, or userinfo fail closed. Attestation errors distinguish expired tokens and temporary verifier/JWKS outages from account mismatch, invalid tokens, and configuration failures so lease owners retry only transient failures. The device-authorization flow also carries this machine's key to the directory and, on completion, keeps the single-use pairing grant the directory minted — in memory only, with a 10-minute TTL, read once throughconsumePairingGrantand cleared on sign-out. That grant is the proof a removed machine needs to re-pair, which is why the desktop's Reconnect affordance andade machines reconnectboth run the device flow rather than the loopback PKCE flow. A definitively rejected grant is marked dead, not deleted (rejectedAt/needsReauth/rejectedReasonon the stored record) andsessionStatereportsactive | signed_out | expired | unreadableso every surface can say which it is; see onboarding and settings → Account session state. -
apps/ade-cli/src/services/account/accountSessionRotationJournal.ts— the crash-safe journal that makes a rotating refresh grant recoverable. The identity provider consumes the old grant the instant the exchange is accepted, but the replacement is only durable once written back; a crash inside that window burns the token family and the next refresh gets a perfectly truthfulinvalid_grantindistinguishable from a revoked session. The journal records that an exchange started against a specific token generation and is cleared once the replacement is durable, so a surviving entry means "the stored token may already have been consumed" and the followinginvalid_grantis not definitive.tryBegincompare-and-swaps that same entry so a live peer already exchanging the grant is waited out rather than raced at Clerk — two POSTs against a single-use refresh token is what produced dailyinvalid_grant/ mark_dead sign-outs. A dead peer's journal is taken over and treated as interrupted. It is stored asaccount.session.rotation.v1, a sibling of the session record, and is deliberately kept in the same file-backed credential bucket — migrating it into the Electron-only store would hide an interrupted desktop rotation from the brain and the CLI, which is exactly the process pair it exists to coordinate. -
apps/ade-cli/src/services/account/accountMachineDirectoryService.ts— account-machine list/delete/rename/adoption for ADE Code and the runtime. Rename writes the account-ownedcustomNamefield without changing the hostname reported by publisher heartbeats; clearing the custom name restores the reported hostname as the display fallback. Directory 401 responses trigger one forced token refresh and exact request retry; a final 401/403 isauth_expired, while transport/server failures remainunavailable. Adoption fences persistence against the captured account owner and session generation before and after pairing, rolling back a newly written account-owned credential if sign-out or an account switch wins the race. The directory'sonlinefield is a short presence lease, not a transport verdict: a machine with a verified secure Relay endpoint remains connectable after that presence bit expires. Every HTTP operation carries one bounded correlation id across the initial request and its one auth-refresh retry, so a user-visible failure can be joined to the Worker's structured lifecycle record without logging an account token or response body. Removal is terminal: the directory records a revocation before deleting the machine row and then asks the relay to purge that machine's Activity, and a failed purge surfaces as a typedAccountMachineActivityPurgeError(machineRemoved: true) rather than a clean success. Getting back on requiresmachinePairingRepair.tsand either proof of a fresh interactive sign-in or a spent pairing grant — pressed by the user, or run unattended bymachinePairingAutoRecovery.tsonce the refusal has outlived the ten-minute quiet window. Seepush-notifications.md. -
apps/desktop/src/renderer/webclient/workspace/WebMachineSessionManager.tsandworkspace/webWorkspaceModel.ts— hosted-browser directory/session projection.mergeWebMachinesmerges account rows with browser-saved environments and cached catalogs into one row per physical machine, rendered byWebConnectionsChip.tsx(the top-bar popover that replaced the Hub page), while the manager serializes admission into a four-client pool, reports Live/Reconnecting/Parked/Offline, retains parked catalogs, and reconnects a parked machine when its project or Chats surface is selected. -
apps/desktop/src/renderer/webclient/adapter/federated.ts— maps the shared renderer's remote-runtime and project APIs onto that pool. Workspace state is persisted per account; each project-scoped adapter is keyed by machine + project so delayed bound operations cannot cross a runtime switch. -
apps/ade-cli/src/services/account/accountMachinePublisherService.ts— the single machine-brain publisher for the account directory. It derives the stable machine key from the cloud-relay store, publishes only currently validated LAN/Tailscale/relay routes, coalesces overlapping work, and sends the account bearer only to the trusted HTTPS directory origin.runServeconstructs and starts it only while the brain holds the machine-wide sync host lease, and disposes it after authority has been lost for longer thanSYNC_HOST_AUTHORITY_RELEASE_GRACE_MS(a project switch never qualifies); the publisher cannot be restarted after dispose, so a later lease acquisition builds a fresh one. A second brain publishing its own endpoints would point phones at a runtime that does not host sync. Its snapshot source is the active project scope'ssyncService.getStatus()when there is one andbuildProjectlessSyncSnapshotwhen there is not, so a machine with an empty project registry publishes on the same terms as any other — see Hosting sync, and publishing, with no project. It reportsno_active_sync_scopeonly when this brain does not hold the lease at all. The published machinenameis suffixed by package channel (publishedMachineName): a Beta build advertises<name> · Betaand an Alpha build<name> · Alpha, while a stable build (or an already-suffixed name) is left untouched, so the same physical computer running two channels shows as two distinguishable directory rows. A LAN endpoint is only emitted for an address candidate whosekindislan; becausesyncPairingConnectInfo.buildAddressCandidatesnow classifies the savedlastHostaslan/tailscalewhen it matches the current address set (instead of the opaquesavedkind that was silently dropped from the directory), a machine's LAN routes publish correctly. Each row also carries the machine's long-lived Ed25519 identity key aspubkey(ed25519:<base64>, frommachineIdentitySigningStore); the publisher fails the publication closed (machine_key_unavailable) rather than advertise a row with a missing key, because that key is what a client verifies before trusting a sealedade-adopt-v1adoption over a non-relay route (see Sealed account adoption in the Security model). Therelayendpoint is gated onrouteHealth.relay.relayBridgeValidated, which the tunnel client now sets proactively (seesyncTunnelClientService.ts), and on the tunnel client's end-to-end verdict — a presentrelayEndToEndVerifiedAtwith norelayEndToEndFailure— so a control that connects but cannot actually round-trip through the relay never publishes arelayendpoint. A verified route can be retained across a transient drop, but a route with a live end-to-end failure is never retained. The relay route therefore appears in the directory without waiting for an external client to open the first tunnel. A 30-second heartbeat keeps the Worker row inside its 90-second online window. Failed publications retry after 1, 2, 5, 10, then 20 seconds so a short outage normally recovers within the lease, and a 401 forces one token refresh before the publication is classified as expired. These operational retries and status polls are local logs, not product analytics. Two failure episodes are the exception, and both go throughapps/ade-cli/src/services/account/episodeAnalytics.ts— a shared edge-triggered helper that emits at most one event while a condition holds and re-arms only once it clears, with a 24-hour deduplication window on top.ade_publish_failingcovers a publication that has been failing for at least two minutes;ade_account_session_unreadablecovers the brain being unable to read the account session at all (sessionReadState === "unreadable", or a status read that threw), carrying only a coarsecodefor the read path (decrypt_failure,no_os_key_material,store_format,session_parse,read_error,unknown) thataccountAuthService.getSessionReadFailureReason()supplies. See logging. Every registration also carriesdeviceIdand, when this host can produce one,hardwareId— the two identifiers the directory dedups a rotated machine key on. The anchor is read on the publish path rather than insidebuildAccountMachineRegistration, because the account id is its salt and the builder has no account context (it also runs on the relay-state poll, which only compares route signatures and sends nothing); a reader that throws or is absent is an ordinary "no anchor" and never the reason a publication fails. It is sent on the heartbeat as well as on a deliberate pairing, because a row can only be matched later if it stored an anchor at some point — but storing one authorizes nothing, and superseding still demands the same proof un-revoking does. A directory that answers withsupersededMachineKeysis reporting which rows it retired for this device; the publisher hands them to the identity store (confirmSupersededMachineKeys, on the same store instance the machine key comes from, so a confirmation cannot be checked against a different read of the same file), logs only the subset this machine actually retired asaccount.machine_identity_superseded_confirmed, and treats an older directory's empty or absent body as nothing to do. Nothing downstream acts on it: it exists so an unexplained rotation is explainable, which is what nobody could do the last time a working MacBook was deleted by hand. Successful account sign-in also requests an immediate publish; the brain observes both its local auth event and cross-process credential-file changes from desktop sign-in. Separately, a lightweight 2-second observer computes a signature for the publish-relevant relay control/bridge state and reachable endpoints. Its first valid snapshot and every later change trigger a coalesced publish, so a newly validated or lost relay route reaches the directory without waiting for the heartbeat. A triggered write becomes the new heartbeat anchor rather than causing a duplicate write at the old deadline. A confirmed Relay identity-conflict recovery also requests another publish as soon as the replacement control route validates. Every publication re-reads the active sync snapshot and token so a brain started before sign-in still recovers. The last typed publisher outcome is exposed asrouteHealth.accountDirectoryinsync.getStatus,ade sync status, and the desktop This computer card, including the selected directory origin, HTTP status, bounded classified HTTP reason, timestamps, and reachable-route count. A non-success response contributeslastHttpReasonand the same reason inskipReason; the parser consumes at most 512 bytes, accepts the Worker's fixed JSONerror/messagefield (or short plain text), strips control/extra whitespace, and never logs or embeds the account bearer. The publisher and desktop account bridge derive the official directory from the same project-aware Clerk issuer resolver, while the machine-ownedADE_ACCOUNT_DIRECTORY_URLoverride remains fail-closed behind the trusted-origin parser. In a packaged runtime, an explicit override of that publisher URL to ADE's development directory is ignored and resolves to the production directory instead. This follows the same atomic packaged Clerk policy as OAuth and attestation resolution: the distributed CLI/brain and Electron entry points setADE_RUNTIME_PACKAGED=1; development Clerk hosts cannot produce a mixed development/production configuration; persisted or environment-provided credentials pinned to a development issuer/client (including an access-token developmentissclaim) are rejected before refresh or publication; rejected environment credentials are treated as absent so they do not block a production login; persisted development sessions are compare-and-deleted only when the store supports an atomic update, followed by one re-read that can surface a peer-written production replacement in the same status call; stores without that primitive leave the value untouched but continue to report it as signed out; andADE_ALLOW_DEVELOPMENT_CLERK=1is the explicit controlled-testing escape hatch. Source-checkout runtimes and non-development custom issuers keep their existing override behavior. -
apps/ade-cli/src/services/account/hardwareAnchor.ts— the one piece of machine identity a reinstall cannot destroy. Both halves of ADE's identity live under~/.ade(the machine key insync-cloud-relay.json, the device id insync-device-id), so a user who deletes that directory and signs in again mints both afresh, the directory's device dedup has nothing to match on, and the account keeps a phantom row for a computer the user owns once. The operating system still knows this machine after the wipe —IOPlatformUUIDviaioreg,MachineGuidvia the GLOBALROOT-resolvedreg.exe(a bareregwould let a planted binary choose this machine's identity),/etc/machine-idor the dbus fallback on Linux. Three rules govern the module. The raw identifier never leaves the process: what goes on the wire issha256("ade-machine-anchor-v2:" + userId + ":" + rawUuid + ":" + adeHomePath), salted with the account id, so one computer signed into two accounts produces two unrelated values and no server-side join can correlate them. An anchor identifies an ADE install, not a chassis: the platform UUID is shared by every ADE on the box (Stable in~/.ade, Beta in~/.ade-beta, a second OS user's home), and hashing it alone made all of them one machine taking turns superseding each other's row, so the canonicalized ADE home path is folded in — a wipe and reinstall lands on the same path and still reproduces the same anchor, which is the whole point. On Windows that path goes throughcanonicalWindowsPathand is lowercased, because an 8.3 short name or a differently-cased spelling of one NTFS directory would otherwise split one install into two machines. It is optional end to end: a VM with no platform UUID, a hardened image with no machine-id, a sandbox that refuses to spawnioregall yield null, and every caller behaves exactly as it did before the anchor existed. The probe is bounded at 2 s and cached for the process lifetime including the negative answer, so a machine with no anchor does not respawnioregtwice a minute.normalizeHardwareAnchorUuidrejects the two ways these lookups "succeed" while saying nothing — an empty value and the all-zero firmware sentinel, which would be shared by every unprovisioned machine on the account. The-v2domain ships no migration and needs none: a v1-hashed row simply stops matching and dedups ondevice_id, as every pre-anchor client's row always did. -
apps/ade-cli/src/services/account/machinePairingAutoRecovery.ts— automatic recovery from "this computer is not in your account any more". Both refusals the directory can answer with are terminal for the heartbeat by design, and were terminal for the machine too: the only way back was a human finding Reconnect this computer, which nobody ever sees on a headless box. This loop polls the publisher every 15 s and runs the identical brain action the button runs — it widens nothing, so a genuine removal is refused exactly as it is today. An episode starts on a latched refusal (machine_revokedorpairing_authentication_required, decoded by the sharedaccountMachineRefusal.tsso the repairer and the reporter cannot disagree about what a response meant) or on a publish leg stuck insnapshot_failedfor two minutes, and attempts run 1 minute, 5 minutes, then hourly. Three gates keep it from arguing with the user. A revocation younger thanPAIRING_AUTO_REPAIR_REVOCATION_QUIET_MS(10 minutes) is left alone: that window deliberately mirrorsPAIRING_AUTH_FRESHNESS_MSin the Worker'scallerToken.ts, because inside it the directory would still accept the sign-in this machine authenticated with — so a repair sent then is precisely the one that would succeed at undoing a removal the user just performed. The budget is the persisted 6-hour allowance in the identity file, not a closure, since the brain restarts far more often than six hours. And a repair with no account session is not attempted at all, so the schedule slips instead of burning budget proving it. Asnapshot_failedepisode gets exactly one cycle: a publish leg that cannot read a snapshot is not a pairing problem. Everything it does is logged (account.machine_auto_repair_episode_started,_started,_failed,account.machine_auto_repaired,_budget_exhausted,_episode_ended) and none of it changes user-visible state — an exhausted budget simply stops arguing and leaves whatever the publisher already reports. -
apps/desktop/src/shared/accountMachineRefusal.ts—readAccountRefusalCode, the single decoder for "why did the directory refuse to register this machine", read by the auto-recovery loop above and by the desktop's reliability telemetry. 403 only: a 401 is an authentication problem with a different repair, and counting it as a refusal both mis-attributes the incident and hides the auth failure behind it. A refusal is the directory looking at a valid caller and saying no. An unrecognised 403 resolves to"other"rather than to null — "turned away for a reason this build cannot name" is exactly the fact the last incident needed — and the server's prose inlastHttpReasonnever travels past this function. -
apps/ade-cli/src/services/power/— the machine's own power and sleep truth, shared by the brain, the desktop main process, and tests.machinePowerReader.tsreads battery/wall power per platform (macOSpmset -g batt; Windows one PowerShell call overroot/wmi BatteryStatuswith aWin32_Batteryfallback, resolved throughresolveTrustedWindowsTooland spawnedwindowsHide: true; Linux by reading/sys/class/power_supplywith no process spawn at all). A machine with no battery reportsbatteryabsent — never0%— and an unreadable read returnsnull, which overwrites nothing; the constant used to be "plugged in", and onepmsettimeout then republished a 20%-on-battery laptop as on wall power.suspendGapDetector.tsis the universal fallback: a 15-second tick that declares a suspend when it fires more than 60 seconds late, reports the whole absence (overdueBy + tickMs), and needs zero platform-specific code — which is what gives Linux and any headless brain sleep detection at all.machinePowerMonitor.tscombines the two into aMachinePowerSource(getPower,getSleepState,getSleepStateAt,getSuspendGapMs,subscribe) emittingsuspend/resume/power-changeevents, each carryingannouncedso a precise host hook is distinguishable from an inferred gap; the poll is 60 seconds and an announced suspend is anchored separately from the gap detector so a 40-second nap does not replay last night's four-hour gap.sharedMachinePowerMonitor.tskeeps one monitor per brain process —getSharedMachinePowerMonitor()owns the lifecycle andborrowSharedMachinePowerSource()hands out a read/subscribe-only wrapper so a borrower cannot dispose an instance others are subscribed to. Its three consumers are the account-directory publisher, the chat service, and the RPC method the desktop uses to forward its pre-suspend beat. -
apps/desktop/src/main/services/power/— the desktop half.powerStateService.tswraps the shared monitor with Electron'spowerMonitor(suspend/resume/on-ac/on-battery) and is a process singleton viagetPowerStateService().machinePowerBrainBridge.tsforwards onlysuspendandresumeto the brain asmachine.reportPowerTransitioninside a 2-second budget: suspend gets exactly one attempt because the machine is already going dark, resume retries four times with 750 ms backoff, and a generation counter supersedes an in-flight resume loop so a staleresumecan never land after a newersuspend. Battery changes are deliberately not forwarded — they ride the brain's own poll.keepAwakeService.tsandsystemSleepConfig.tsback the opt-in keep-awake setting; see onboarding and settings. -
apps/desktop/src/shared/types/power.tsandapps/desktop/src/shared/machinePresence.ts— the shared vocabulary every client renders from.MachinePoweris{ battery?: { percent, charging }; onExternalPower }, wire-flattened throughtoMachinePowerRecord/fromMachinePowerRecord.resolveMachinePresenceis the single decision: a freshasleepannouncement outranksconnected(a channel to a sleeping machine does not report itself closed), thenconnected, thenonline, then a heartbeat insideMACHINE_SLEEP_INFERENCE_WINDOW_MS(10 minutes) is inferredasleep, otherwiseoffline.machinePresence.tsaddsconnectedMachineIds(two sets —machineKeyanddeviceId— because onlymachine_keyis unique, and merging them renders two rows Connected off one channel),machinePowerPhrase("82% battery"→"plugged in"→"on battery", battery always winning), andmachineStatusLine/machineActionLabel("Wake"when asleep, else"Connect"). The Swift twin isapps/ios/ADE/Services/SyncMachineWake.swift, which must agree down to the inclusive staleness boundary and the wording of the power phrase. -
apps/ade-cli/src/services/credentials/credentialStore.ts— the per-machine credential store behind the account session. Two implementations share one interface.EncryptedFileCredentialStoreowns the AES-GCM.ade/secrets/credentials.json.encfile that the brain, theadeCLI, and the desktop app all read;ElectronSafeStorageCredentialStoreowns the Electron-onlycredentials.safe.enc. Because the brain cannot read a safeStorage file,FILE_BACKED_CREDENTIAL_KEYS(account.session.v1,sync.bootstrapToken.v1) are pinned to the file store: the safeStorage migration copies everything else across, retains those keys in the file store, prunes the now-duplicated migrated keys out of it, and keeps.machine-keyalive; only a legacy store with nothing retained is deleted.setSync/updateSyncon the safeStorage store throw if a caller tries to write one of those keys back into the Electron-only file — otherwise a machine whose app is signed in silently signs its brain out. A legacy store that reads backunreadableaborts the migration outright rather than migrating an empty view of it and then deleting the ciphertext and machine key. Reads try the OS-bound key first and the bare machine key second (a second-candidate hit is genuine legacy ciphertext and is rewritten); if neither works, the store self-heals once per 30 s by dropping the cached OS key material and retrying against a fresh read, then reportsgetLastReadState() === "unreadable"with a coarsegetLastReadFailureReason()ofdecrypt_failure,no_os_key_material, orstore_format. It never writes an empty store over ciphertext it could not decrypt. The Electron store records the same two verdicts, because it has one branch that returns an empty view instead of throwing: an aborted legacy migration, where there is no safeStorage file and the legacy file store could not decrypt the one that exists. Returning{}there without saying so is what let a machine with credentials on disk render as one that was never signed in — see onboarding and settings → GitHub connection status, where the same distinction drives the desktop'scredentialStoreUnreadablestate. -
apps/ade-cli/src/services/credentials/osBoundKeyMaterial.ts— everything about obtaining the machine-local secret the file store's key is derived from: thesecurityinvocations, the process-wide cache, the negative-cache backoff, and the create race. Resolution is race-safe and non-destructive.add-generic-passwordruns without-U, so a process that loses the create race adopts the winner's item instead of clobbering it, and any inconclusivesecurityresult (timeout, locked keychain, denied access) fails closed rather than minting a replacement — two first-run processes each minting their own secret is exactly what made one of them unable to decrypt what the other wrote. The synchronous path may create the item; the asynchronous path is read-only and never participates in the race. The two paths use different backoffs: the read-only path backs off on any miss, while the creating path backs off only when the keychain was unavailable, so anot_foundmiss can never starve first-run item creation. -
apps/account-directory/src/directory.ts— the Clerk-scoped machine register/list/delete Worker routes. Machine listing selects the owner's 500 most recently seen rows before computing online-first order and exposes separate authentication and D1 durations throughServer-Timing; the trusted web-client CORS response exposes that header. Every request also receives a validated/generatedX-ADE-Correlation-ID, echoed on the response and included in one privacy-safe structured completion log; trusted web CORS exposes the id and allows the request header. Registration also supersedes phantom duplicates: because machines are keyed(user_id, machine_key), a client that rotates its identity file arrives as a second row for one physical computer, and the owner then deletes whichever row looks stale — half the time the live one. A register call whosedeviceIdorhardwareIdmatches other rows on the same account deletes them (at mostMAX_SUPERSEDED_MACHINES, five, oldest-seen first) and reports them assupersededMachineKeys, a field that is additive and omitted when empty. Both identifiers are caller-supplied and therefore forgeable, so on a plain token they authorize nothing: the call must carry the same proof un-revoking needs. It folds rather than merely deleting — the one thing a superseded row holds that the new one cannot rebuild iscustom_name, so the most recently seen superseded name is carried onto the survivor, and only onto a survivor with no name of its own, since a name set on the new row is the fresher statement of intent. The carry-forward and the deletes go out as oneDB.batch(), because the pairing grant is already spent by the time they run and a half-finished loop would strand phantoms with no credential left to clear them. Superseded keys get norevoked_machinesrow: the physical device holds the new key, and blocking the old one would trapdoor any client that rolls its identity file back; the relay is not called either, because the device never left the account and its Activity is still the user's own. -
apps/account-directory/src/callerToken.ts— Clerk token verification for every route that takes a caller bearer, and the definition of proven-recent interactive authentication.pairing: truearrives in the request body, so on its own it is an unauthenticated client boolean — a removed-but-still- signed-in machine could set it on its next heartbeat and make the Worker a confused deputy clearing its own removal. Authentication time is the credential a removed machine cannot mint: a background heartbeat carries an old authentication even after its access token is refreshed (a refresh renewsexp/iat, never the moment a human authenticated), while a real sign-in carries a new one.PAIRING_AUTH_FRESHNESS_MS(10 minutes) is the bound, read fromauth_timeor Clerk'sfvaand never fromiat, and it fails closed — a token with no such claim proves nothing, which is why a pairing grant exists as the second path. The module declares the slice of the env it needs rather than importingEnv, which is what keeps an import cycle back intodirectory.tsfrom forming. -
apps/account-directory/src/pairingGrants.ts— minting, reserving, consuming, releasing, and expiring the single-use grants. A spend is two phases, not oneDELETE: an atomicUPDATE ... SET reserved_atwhoseWHEREstill carries every rule (this user, this machine, inside its TTL, not already held), proven bychanges === 1, and then either a scopedDELETEonce the relay agrees orSET reserved_at = nullwhen it does not. Destroying the grant before knowing the relay's answer meant a relay outage burned the only credential a reinstalled machine had — the same lockout the grant exists to prevent, moved one step later. A release restores the row exactly as it was,expires_atincluded, so forcing relay failures buys an attacker nothing beyond the original TTL; a reservation older thanPAIRING_GRANT_RESERVATION_MS(60 s) counts as unheld, so a Worker that dies mid-hand-off strands the grant for a minute rather than until it expires. -
apps/account-directory/src/activityRelay.ts,logging.ts,trustedOrigin.ts— the relay hand-off (revocation clear and Activity purge), the structured-log helpers, and the CORS origin rules, split out ofdirectory.tsso each has one owner. Every refusal path emits exactly one line —directory.register_refused,.remove_refused, or.supersede_refused— carrying the wirecodethe client received, an optional finerreason(no_proofversusgrant_rejected, or the relay's own failure text), and the request's correlation id. Every refusal is a user who cannot get their computer back onto their account, and by the time they ask for help the request is gone; Workers observability runs athead_sampling_rate: 1so the line is always there. Identifiers appear as 8-character prefixes only — a machine key is capability-shaped and a grant is a live credential. There is deliberately no admin restore route: it would be a new authentication boundary guarding exactly the tableswrangler d1 execute --env productionalready reaches, so support recovery is a direct D1 statement after these logs identify the row. -
apps/account-directory/src/diagnostics.ts—POST /diagnostics/upload, the write-only R2 sink behind the desktop's Send to ADE action andade report-issue --send. It is matched inindex.tsbefore the directory router, because it is the one route here that is not account-scoped and the directory's exact-origin CORS rule and 404-on-unknown-OPTIONSfit neither an unauthenticated Electron renderer nor a CLI. Authentication is optional but never silently downgraded, the body is capped at 512 KB by bothcontent-lengthand a counted stream, and there are two quotas, because they bound different things. The per-caller one is five a day per signed-in user or percf-connecting-ip. The fleet one —DIAGNOSTICS_DAILY_GLOBAL_LIMIT, default 400 stored reports per UTC day across every caller, claimed fromdiagnostics_upload_days(migration0009) by the same single upsert idiomdevice_approval_rate_limitsuses, refunded when theputthen fails, and failing closed to503when D1 is unavailable — exists because clients now send reports automatically on failure, so one bug firing across the install base multiplies "five each" by the install base and no per-caller limit can see that coming. The two429s carry distinct bodies (rate limitedversusdaily diagnostics budget exhausted) on purpose: an auto-sender that read a fleet-wide stop as its own quota would retry forever. Uploads also carry optionalauto/failureCodemetadata so automatic and hand-pressed reports stay separable in the bucket. See storage and recovery → Diagnostic reports for the client half, andapps/account-directory/README.mdfor the full contract and the R2 bucket + lifecycle setup the deploy does not do for you. -
apps/desktop/src/shared/accountDirectory.ts— canonical account-directory origin, bounded success/error response decoding, route allowlisting, machine selection, and paired endpoint validation shared by desktop, the brain, ADE Code, and hosted web. List calls can force-refresh once after a 401; a final 401/403 becomesauth_expiredwith the Worker's short fixed reason preserved for CLI and desktop diagnostics, while oversized or unrecognized bodies fall back to the generic session-expired message.accountMachineConnectionStatereportsavailablewhenever a directory-verified secure endpoint exists, even if the short-livedonlineheartbeat expired; only a row with no dialable endpoint isoffline/unreachable. -
apps/ade-cli/src/eventBuffer.ts— bounded runtime-event replay buffer used by multi-project RPC and desktop/TUI event streams. Retains up to 10,000 events / 16 MB total / 1 MB per event by default, emits live subscribers best-effort even for oversize events, and returnseventEpoch,gap, andoldestCursorfromdrain()so clients can reset stale cursors when a daemon restarts or history was evicted. -
apps/ade-cli/src/multiProjectRpcServer.ts— machine-level JSON-RPC surface forprojects.*,sync.*(includingsync.runSelfProbe, which resolves the active sync host and runs the tunnel client's relay end-to-end probe, or returns a skipped verdict when no host is active),runtimeEvents.*, project-scopedade/actions/call, and project-independentpersonalChats.call/personalChats.streamEvents.sync.getStatuswith no active scope answers from the injectedgetProjectlessSyncSnapshot— the brain passes the real builder, and a process that has none falls back tobuildDegradedProjectlessSyncSnapshot— rather than the fixed all-down literal it used to return, which misreported a hosting brain as unreachable toade doctorand to the account-directory publisher. Runtime-event subscribe replies include the gap fields above;projects.listresolves at most 24 host-side icons within 750 ms, with 128 KiB per-icon and 512 KiB aggregate wire caps, so large project registries cannot stall remote desktop or mobile catalog setup just to inline artwork.projects.getHandoffStoragePreflightchecks the destination parent path, write access, target collision, free space, and destination-local Git access before the desktop offers to clone a missing handoff repository.projects.add/create/cloneand the newprojects.setCatalogVisibilityaccept a registration intent (catalogVisibility: "recent" | "system"+registrationSource) so the caller declares whether a project should show in the phone catalog and roster; unspecified callers default toSYSTEM_PROJECT_REGISTRATION. The registry file (projectRegistry.ts) is now version 2 — records carrycatalogVisibilityandregistrationSource, and a one-time v1→v2 upgrade seeds"recent"for roots that match the desktop's recent-project list (passed in aslegacyRecentProjectRoots) or are an existing Git checkout, marking everything else"system";add()upgrades a"system"row to"recent"on an explicit recent registration but never demotes. Desktop local-runtime action routing uses that default registration only to obtain a project id: an already-cached registration for the same root satisfies the default request, so routine PTY/file actions after a project switch cannot overwrite an explicit recent/desktop registration withsystem/runtime-autometadata. -
apps/ade-cli/src/services/personalChats/personalChatScope.ts— lazy machine-owned personal runtime injected into both sync ingress paths. It validates personal session ownership and exposes the durable transcript path and active-turn state used bychatScope: "personal"subscriptions.
Desktop connection UI:
-
apps/desktop/src/shared/types/sync.ts— the account-directory state union plus the two things every consumer of it needs:isSyncAccountDirectoryState, which narrows a state that arrived over RPC as an untyped string (widening at the trust boundary rather than in a caller's signature, where it would silently disable the exhaustiveness check), anddescribeUnpublishedAccountDirectory, the per-state{ summary, nextAction }advice for a machine that is signed in but not published. That table is the only place this copy lives; the Connections pane andade setupboth read it. They previously kept hand-mirrored copies that had already drifted — the pane covered every state, the CLI covered one and printed the publisher's rawskipReasonfor the rest. Never renderskipReasonto a user: those strings are internal diagnostics ("No active sync scope is available.") that name a fault and say nothing about clearing it.nextActionis a CLI command, so a surface that has a button for the same fix (the pane's Repair control fortoken_unreadable) drops it and renders the summary alone.The union separates two states that look identical from the outside and mean opposite things.
sync_disabledis sync genuinely off: nothing is trying, and there is nothing to wait for.sync_not_startedis sync that is meant to run and has not come up — the publisher has not attempted yet, or the sync host is still failing and retrying. Both used to reportsync_disabled, which told a user whose sync host was crash-looping that sync was switched off and sent them hunting for a switch that was already on.sync_not_startedreads "sync hasn't started on this computer yet" and offersade doctor, which is the surface that can actually say why. It is produced bysyncService.tsandprojectlessSyncSnapshot.ts, and by the account publisher's initial health before its first attempt. The Machines panel treats it as a non-publishing state rather than a failure (PUBLISH_INACTIVE_STATES), so a brain that is merely still coming up never raises the publish-failing alarm.unpublishedMachineLabelalso guards the lookup withisSyncAccountDirectoryStatebefore destructuring. A newer brain can name a state this desktop build's union does not have; that used to throw aTypeErrorand blank the entire Connections pane, and now degrades to "Signed in — sync state isn't available on this computer yet". -
apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts— turns that advice into the one Connections line:Signed in — <summary>. -
apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx— hosts therelay-offlinebanner alongside the GitHub/AI-provider family.AppShellseedsrouteHealth.relayfromsync.getLocalStatus(the physical machine's relay, not whichever runtime a remote-bound project routes to) via the sharedlocalSyncStatusReader, so its read of a broadcast coalesces with Connections' read of the same one, then keeps it current from the existingsync-statusbroadcast, committing a new object only when a field the banner reads actually changed.deriveRelayOutageStatereports"suppressed"immediately — nothing recovers a machine-local ownership conflict on its own, so a grace period would only delay the fix — and"down"only afterRELAY_OUTAGE_GRACE_MS(2 minutes) of uninterrupted outage measured fromrelayControlFailingSinceMs, which is past every ordinary sleep/wake or Wi-Fi-hop redial. The threshold is crossed on wall-clock time, sorelayGraceRemainingMsarms exactly one timer for the remaining grace rather than polling. The dismiss key is global (relay identity is machine-wide) and its fingerprint is the outage state, so dismissing a plain outage cannot hide a later process conflict. -
apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx— the single top-bar Connections surface with Machines, Phone, and Web tabs. The panel owns its header close control and passes the current in-app route to the Account page so signed-out users can return to the exact surface they left. -
apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx— Connections uses the focused"phone"and"web"variants beneath a shared This computer card. The card owns the pairing-PIN manager and the internal phone QR; the Phone tab explains sign-in, QR + PIN, and Nearby + PIN, while Web is account-sign-in only. When a configured PIN is available only as its at-rest PBKDF2 hash after a runtime restart, the This computer card can generate and set a new six-digit PIN instead of leaving copy disabled. Initial-load failures show a short recovery action while keeping the raw message under Technical details: missing project registration asks the user to open a project, a build running from its output directory asks the user to install this build and reopen it from the installed copy — that copy names no folder, because the macOS Applications folder it used to name does not exist on Windows — and other sync-service failures ask for an ADE restart. When the account-directory state is the one failure a restart actually clears —isBrainAccountSessionFailure(...)inshared/types/sync.ts, currently exactlytoken_unreadable— the This computer card renders a Repair control next to the directory summary; the re-read that follows a repair is forced, because a repair is a user action and must not wait out the degraded-read backoff window. The local-brain-onlywindow.ade.sync.getLocalStatus(...)accessor is available for the card to consume so a window bound to another machine can still show the physical computer's identity, pairing code, and Phone/Web device lists. -
apps/desktop/src/renderer/lib/localSyncStatusReader.ts— the single shared reader forade.sync.getLocalStatus. Coalesces concurrent readers onto one in-flight invoke, backs off exponentially (1s → 30s) while the local runtime reportsdegradedReason, honors{ force: true }for user-initiated reads, and settles last-issued-wins so a fast forced read is not overwritten by an older shared read timing out behind it. ExportsisDegradedLocalSyncSnapshotandlocalSyncStatusBackoffMs. -
apps/desktop/src/renderer/components/settings/useSyncConnections.ts— the hook that keeps the Connections panel local-vs-remote aware. It fetches the binding-followingsync.getStatusand the machine-levelsync.getLocalStatuson every refresh — the latter through the sharedlocalSyncStatusReader, withrefresh({ force: true })for the reads a person asked for (panel open, Try again, post-mutation re-read) and plain unforced reads for the broadcast/interval refreshes; the This computer card always renders thegetLocalStatussnapshot, so it names the physical computer even in a remote-bound window, and never substitutes a routed (remote) snapshot when the local one is unavailable. It derivesisRemoteBoundby comparing the two snapshots'localDevice.deviceId, exposes the bound machine's display name for labeling, and gatescanManageDeviceson!isRemoteBound. Because the mutation methods (setPin,generatePin,clearPin,forgetDevice, name edits) still follow the window binding, the panel renders the pairing code and device controls read-only while remote-bound and labels the connected device list with the local computer's name so it can't be mistaken for the bound machine's. When remote-bound it also scopes the shown devices to this computer's liveconnectedPeers(viapeerToRuntimeDeviceState) instead of the routedlistDevices()result, which would describe the remote machine; offline-paired rows are unavailable in that mode until a local-scoped device IPC exists. It also exposesrefresh()— a one-shot re-read of both snapshots without the initial-load spinner — which the Repair control uses to re-evaluate its banner once a restart settles. -
apps/desktop/src/renderer/hooks/useBrainRepair.tsandapps/desktop/src/renderer/components/settings/BrainRepairButton.tsx— the shared Repair affordance for a brain that cannot read the stored account session. The hook callswindow.ade.app.restartBackgroundService()(ade.app.restartBackgroundService), which restarts this Mac'scom.ade.runtimelaunch agent and resolves only once the replacement answers a ping — readiness is observable only in the main process, so the renderer awaits it rather than sleeping and hoping. The IPC is optional inglobal.d.ts: the hosted-web adapter and browser mock cannot touch a launch agent, sorepair.availablefeature-detects before any surface offers the button. A rejected restart renders "Repair failed — quit and reopen ADE." with the technical detail in thetitle;onSettledruns on both paths so the caller's banner is re-derived either way. The button accepts an optionaldisabledso a sibling action (Account Reconnect this computer) can block Repair while it is in flight. The This Mac card, the Machines panel's route-publish row, and AccountYourMacsCardmount the same hook and button. -
apps/desktop/src/renderer/components/account/YourMacsCard.tsx— Account Your computers directory UI (extracted fromAccountPage). When this computer is missing from the signed-in list it offers Reconnect (directory re-pair viarepairMachinePairing/ device login) beside Repair, with session-state-aware copy fromdescribeThisComputerMissingthat does not treat absence as proven removal. See onboarding and settings. -
apps/desktop/src/shared/runtimeErrors.ts— canonical cross-process error messages and predicates shared by the local-runtime pool, main IPC fallback, preload routing, remote-runtime connection/timeout reconciliation, and the Connections recovery copy. Keep these predicates aligned rather than duplicating message regexes at each boundary. -
apps/desktop/src/renderer/components/app/TopBar.tsx— the Connections control, connected summary, dialog portal/focus ownership, and external requests that open a specific tab. Browser peers are identified bydeviceType === "browser". -
apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsxandapps/desktop/src/renderer/components/app/TopBar.test.tsx— focused Phone/Web variants, This computer PIN management, internal phone-QR interactions, account-only web guidance, web-peer chip, and sheet dismissal coverage.
Cross-machine Work union:
apps/desktop/src/renderer/state/crossMachineLanes.ts— renderer-owned, repository-scoped projection of each connected machine's lanes, bounded session preview, and mapped PR rows into the active Work sidebar. It is not CRDT replication and does not change the project tab's runtime binding. A PR row lives in the.adedatabase of the machine that owns the lane, so each machine's slice carries its ownprs, read withpr.listAllon the lane cadence (in parallel with the lane and session reads on a cadence tick; sequentially only for the off-cadence catch-up, so a slow PR round trip never holds a machine's lanes and chats out of the store). The read is best-effort and omission-retaining: a machine that answerslane.listbut failspr.listAllstill contributes its lanes and sessions and keeps its last reported PR rows.decodeForeignPrsapplies the same drop-don't-half-decode contract asdecodeForeignLanes, validating every field the badge path reads (id,laneId,headBranch,githubPrNumber,githubUrl,state) so a peer on an older build cannot produce aPR #undefinedchip or one whose click is a silent no-op. See Pull requests. A detached chat created against anotherOpenProjectBindingis optimistically filed in that binding's machine slice using its stable session id and resolved lane name; binding-scoped reconciliation retains it across stale list responses, then replaces it when the owning runtime returns the authoritative row. Foreign lane presentation applies the same active/snoozed/settled filing and quiet collapse rules as local lanes while keeping runtime-pinned actions directed to the owner. Its machine marker follows the physical Mac, not the selected tab binding:isActiveBindingdecides where the lane renders, whileisThisMachinedecides whether the amber elsewhere glyph appears. Thus a remote-bound tab still labels every remotely owned lane, including those in its primary list rather than the foreign union.apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx,SessionListPane.tsx, andapps/desktop/src/renderer/lib/terminalAttention.ts— route chat-created ownership metadata into the local or foreign optimistic path and render both through the relationship-awareeffectiveSessionFilingBucketswrapper around the sharedsessionFilingBucketlifecycle-plus-snooze contract. Open chat and lifecycle controls read the local cache first, then the root cross-machine snapshot for a foreign row, and keep their filing fresh with bounded render-only deadline timers.apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts— per-session machine routing for the union. A CLI or shell session on another machine opens in place with its owningOpenProjectBindingcarried as a per-session runtime pin, exactly like a chat: the PTY spawn/write/resize/ dispose calls, terminal preview, transcript reads, and PTY data/exit subscriptions all target the owning machine while the project tab stays where the user put it. Switching the tab is reserved for a session whose binding this window does not have open. Local sessions resolve to anullpin and keep the unpinned path unchanged.apps/desktop/src/renderer/components/terminals/useLanePrs.tsandapps/desktop/src/renderer/lib/lanePrBadge.ts— the lane→PR map and the navigation contract for its chips. Because cross-machine handoff copies a lane id, "which machine" is part of the identity of a PR lookup: the map has three namespaced key spaces and no bare lane ids —bound:<laneId>(boundMachineLanePrs),<machineId>:<laneId>(lanePrsForMachine), andany:<laneId>(laneHasAnyPr, used only by the Has PR filter chip). A lane's value is plural: its current-branch row isactive, retained rows from previous branches areprevious, and the collapsed badge rolls them up without hiding a worse CI or review state. A session or chat narrows that set to its explicit PR links when present; legacy rows without an edge keep the recent lane fallback. A session card marks its badge foreign from the presence of a foreign row, not from a runtime pin — an unreachable machine's row has a null binding and no pin while still being foreign — andopenLanePrsends a foreign PR to GitHub because the PRs tab can only resolve a PR id on the bound machine.
Cross-machine Work chat handoff:
apps/desktop/src/renderer/components/chat/AgentChatPane.tsxandCrossMachineHandoffModal.tsx— Handoff-tab entry point and the staged source/destination/clone/review/completion UI, including the destination chat's model / reasoning / fast-mode / permission controls and the fast-forward offer for a clean-but-behind destination lane.apps/desktop/src/renderer/components/chat/crossMachineHandoffPresentation.tsx— the modal's pure presentation half: stage/mode types, theSourceCheckshape, the branch-row and route/repo-readiness copy, permission tone and icon lookups, send-step labels, and theCheckRowcomponent. Split out because these are exactly the pieces that shipped wrong (a tone map that rendered every permission pill grey, a branch row that called a two-commits-behind branch "pushed") and were unreachable from a test inside the stateful modal.apps/desktop/src/renderer/components/shared/BlockedAction.tsxandapps/desktop/src/renderer/components/shared/PermissionModePicker.tsx— cross-surface primitives the modal reuses rather than reimplementing: the reason-carrying blocked-action button/list, and the composer's permission pill.apps/desktop/src/main/services/chat/agentChatService.ts— authoritative source readiness, capsule creation and validation, destination preflight, deterministic lane/chat acceptance, durable replay record, and source notice; it also owns the one-call session metadata regeneration action for bound chats.apps/desktop/src/shared/crossMachineHandoff.tsandapps/desktop/src/shared/types/chat.ts— portable Git identity/sanitization, untrusted response decoders, capsule DTO, preflight/result contracts, and the shared session-metadata field/result DTOs.apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts,remoteConnectionService.ts, andapps/desktop/src/main/services/ipc/runtimeBridge.ts— destination machine capability checks, storage preflight dispatch, route pinning, paired/SSH runtime JSON-RPC routing, request-local timeout policy, and non-replayable action reconciliation when confirmation is lost. The pool also retains each paired target's liveSyncRuntimeTransport(not just the port-forward client derived from it), becauseconnection.hello.featuresis the host's capability advertisement andconnection.endpointis the only place its address and port exist on this side — both are needed bygetAttachmentUploadRoute.apps/desktop/src/main/services/remoteRuntime/attachmentUploadRoute.tsandattachmentUploadClient.ts— the desktop client half of streamed attachment upload.resolveRemoteAttachmentUploadRoutecomposes the route from three independent facts, each of whose absence is an ordinary fall-back to base64 rather than an error: the host advertisedfeatures.attachmentUploadV1;hello_ok.connectionTransportisdirect, because a relay-routed connection terminates at the relay, which brokers WebSocket frames and nothing else; and the endpoint is aws:/wss:URL that maps tohttp:/https:(the sync HTTP server and the WebSocket share one port, so the origin is the endpoint's with the scheme swapped and the path replaced). SSH targets are not paired transports at all and return null.uploadRemoteAttachmentthen pipes the file from disk — never buffered, which is the entire point of the leg — with the ticket as its only credential, bounds the response read so a misbehaving host cannot stream into this process's memory, and stops at a 5-minute budget. The ticket names the route path and the capability names the origin; they are composed rather than either being trusted alone, so a host that moves the route in a later version is followed with no client change.apps/desktop/src/preload/preload.ts— source project-runtime routing plus the renderer bridge for machine-level project setup and destination actions, including the boundagentChat.regenerateSessionMetadatacall.
Canonical files (apps/ade-cli/src/services/sync/):
-
productAnalyticsRemoteCommand.ts— treats browser/phone analytics input as untrusted, binds the peer surface and canonical host project, strips claimed identity fields, and applies the peer's consent bit before dispatch. Paired consent never changes the machine-wide preference. -
syncService.ts(~1,160 lines) — orchestrator that wires the runtime, peer client, device registry, draft persistence, pin store, and the per-project / per-runtime configuration. Builds theprojectCatalogProviderso a runtime hosting multiple projects can hand a phone a catalog and react toproject_switch_request. AcceptsforceHostRoleonly as a legacy override; normal callers leave it false so a second runtime becomes a viewer instead of stealing the sync authority role. Its route-health derivation lives insyncRouteHealth.ts, shared with the projectless path. Host port candidates come frombuildSyncHostPortCandidates(8787 always first; a stickylastPortof 8788 is tried next, never instead). When the shared listener lands off 8787, the service schedules a canonical-port hot-migrate (tryMigrateToPort) — first probe at 2 s, then every 15 s — so a replacement brain that briefly fell back to 8788 steals 8787 back once the wedged holder dies, updateslastPort/ the lease, refreshes LAN discovery, and republishes the account-directory endpoints without a restart. -
syncRouteHealth.ts—deriveListenerHealthandbuildRelayRouteHealth, the one derivation of how a machine describes its own inbound routes. BothsyncService.getStatus(project scope) andbuildProjectlessSyncSnapshot(no scope) call it with the same raw inputs. The strings are what the user reads in Connections andade doctor, and the account-machine publisher gates on the booleans, so the two paths disagreeing is not cosmetic drift — it is one of them lying about whether the machine is reachable. The genuine differences are parameters, not branches: what "not bound" means (a listener that failed to start vs a machine with no scope), and which timestamp stands in when the tunnel reports no failure time. A loopback validation result counts only while it still names the currently bound port, so a rebound listener is never reported healthy from a stale probe. -
projectlessSyncSnapshot.ts— theSyncRoleSnapshotfor a brain with no project scope. See Hosting sync, and publishing, with no project.buildProjectlessSyncSnapshotreports the real listener, machine identity, pairing connect info, and relay when this process holds the lease and has the shared listener bound;buildDegradedProjectlessSyncSnapshotis the honest all-down shape for callers with neither. It replaced a hardcoded placeholder that claimedlistenerBound: falseandpairingConnectInfo: nullfor a genuinely bound listener — the two fields the account-directory publisher gates on. OptionalpinandbootstrapTokenargs let a caller that holds the machine stores report the real pairing state; both are gated onhosting, so a runtime that is not the machine's sync host never leaks a plaintext PIN or bootstrap token. -
syncHelloProtocol.ts— the one parser forhelloandpairing_requestpayloads, imported by both ingress paths (syncHostServiceandbrainProjectActionsSyncHandler). Normalizes the legacy top-leveltokenintoauth: { kind: "bootstrap" }, validates each of the four auth shapes (bootstrap,paired,account,account_sealed), bounds the carried relay account token, and parsesconnectionAttempt(id capped atCONNECTION_ATTEMPT_ID_MAX_CHARS, timestamps no further ahead thanCONNECTION_ATTEMPT_MAX_FUTURE_MS) plusdbVersionBySiteand the application-compression offer. There is no second copy; the brain's narrower hand-rolled one is gone. -
syncAccountHelloAuth.ts— the shared account-hello gate chain for both ingresses, and the canonical rejection strings (SYNC_REPAIR_REQUIRED_MESSAGE,SYNC_ACCOUNT_SESSION_CHANGED_MESSAGE,SYNC_ACCOUNT_VERIFY_UNAVAILABLE_MESSAGE,SYNC_ACCOUNT_NOT_SIGNED_IN_MESSAGE,SYNC_ACCOUNT_DEVICE_MISMATCH_MESSAGE,SYNC_ACCOUNT_KEYLESS_RECORD_MESSAGE,SYNC_ACCOUNT_OTHER_OWNER_MESSAGE,SYNC_ACCOUNT_PAIRING_WRITE_FAILED_MESSAGE,SYNC_ACCOUNT_VERIFY_FAILED_MESSAGE). "No usable pairing record" and "unknown device" deliberately return the same wire answer: distinguishing them would turn the handshake into an existence oracle for an unauthenticated caller. Connection arbitration and sealed adoption are options the brain leaves unset, which is the only genuine divergence between the two callers. -
pairedDeviceRejectionLimiter.ts— per-deviceIdthrottle for repeated paired-hello rejections. A phone that kept a pairing secret after the host forgot the record will race LAN + Tailscale + Relay forever; without a cap that is thousands ofpaired_device_rejectedlines a day. The limiter samples warn logs and delays later rejects. It never seesunknown_devicevssecret_mismatch— a different cadence per reason would leak existence to an unauthenticated caller. Both ingresses share it. -
brainMachineSyncStores.ts— the machine-level PIN / pairing / security stores for a brain hosting sync with no project scope (sync-pin.json,sync-paired-devices.json,sync-security.jsonunder~/.ade/secrets/), memoized per resolved secrets directory under apathKeyso one directory can never yield two store sets. Also exportsgenerateMachinePairingPinandcreateProjectlessSyncControls, theProjectlessSyncControlsimplementation the RPC surface falls back to. The ingress handler and the RPC surface share these instances, so a PIN generated over RPC is live for the very next handshake instead of after a restart. -
machineRelayTunnel.ts—createMachineRelayTunnel, the machine's one relay tunnel client plus its authority gate. Both brains that can host phone sync build it:createAdeRuntimefor a project scope, andrunServe's projectless path for a machine with nothing registered. They must produce the same thing, because the relay Durable Object keeps one host control socket permachineKeyand evicts the previous holder with4505— two clients on one machine evict each other in a loop and relay stays down for both. The client is cached one-per-machine keyed by the relay config path, so a project scope booting after the projectless brain adopts that brain's client instead of registering the samemachineKeytwice. The host listener is attached outside the factory and before the gate: whichever runtime actually owns the listener wins regardless of who created the instance, and the gate's firststart()already has a bridge to validate. Only the reaction to a publication-state change differs between the two callers, so that stays a parameter. Its account lease asks "is this machine still theirs", not "can I call the API": a process-lifetimeretainedAccountOwnerIdkeeps the tunnel up through anexpiredorunreadablesession (leaseexpiresAt: null, no token churn against a grant already known dead), and only a deliberatesigned_outdrops it. See remote runtime → Account state and reachability. -
syncHostService.ts— the per-project WebSocket host. Owns connection acceptance, hello/pairing handshakes (anauth_failedrejection is attributed with the rejecting machine'shost: { deviceId, name }— read fromreadBrainMetadata()— so a client can only ever drop a saved pairing when the rejection came from the machine it is actually paired with), the sealedade-adopt-v1account-adoption handshake (account_challenge→account_challenge_ok→ ahellowhoseauth.kindisaccount_sealed: the host signs the client's nonce over an ephemeral X25519 exchange with itsmachineIdentitySigningStorekey, derives the session key, unseals the account credentials from the sealed hello, and returns the paired credentials in a sealedhello_ok. The seal AEAD is negotiated from the client's advertisedsupportedAeadsagainst the host'ssupportedAdoptChannelAeads()— the host picks the first mutual choice, echoes it in theaccount_challenge_ok, and binds it into the signature input, so a packaged Electron without ChaCha20-Poly1305 negotiatesaes-256-gcminstead of failing to connect; a client advertising an AEAD set with no host overlap is rejected rather than silently downgraded. Legacy adopters that omitsupportedAeadsstill fall back tochacha20-poly1305for compatibility; that choice is not present in their signed transcript. The host recordssync_host.legacy_adoption_aead_unboundwith the client version when that path completes.ALLOW_LEGACY_UNBOUND_ADOPTION_AEADnames the sunset gate: once the supported-client floor guarantees AEAD advertisement, theaccount_challengehandler can reject an omitted list and make transcript binding unconditional. A completed single-use challenge is required, well-formed challenges feed no rate limiter while malformed/anomalous ones charge a per-IP + global cooldown, and unsealedaccount_sealedadoption is the one account path allowed over a direct LAN/tailnet route — plaintextaccountbearers still require therelay-bridgetransport origin), per-peer state, application-compression negotiation (the client offers ordered codecs inhello, the host selectsdeflatein the legacy-encodedhello_ok, and both directions switch only after that frame is queued), authenticated inboundenvelope_chunkreassembly, outbound per-peer chunk framing after the peer declaredchunkedEnvelopes, and protocol-range rejection through a typed uncompressedhello_errorbefore close code4406, changeset fan-out + ack tracking (bounded, windowed exports and smaller-batch recovery from the last acknowledged cursor — seecrdt-model.md), bounded parallel reads on the per-peer envelope queue (up toMAX_CONCURRENT_PEER_READS = 4reads overlap, while every other envelope stays a barrier: a write waits for all preceding work and every later read waits for that write, so a read still observes every mutation the peer sent before it, and the only new interleaving is read-with-read — which these services already face from the desktop renderer's parallel IPC calls. It replaced one fully serialized chain, where a cold search-index build or a git blame head-of-line-blocked every other read from the same client.isConcurrentReadEnvelopeclassifies conservatively: file reads by action name, remote commands only when the method segment is a plainget*/list*/read*/search*accessor, and everything else — including verbs that only look harmless, likegit.fetch— is a write. A read waits for the preceding write barrier before taking a slot, so it cannot starve an earlier read the write is itself waiting on, and a released slot is handed straight to the longest-waiting read to preserve FIFO order. The handler timeout starts when the handler starts, never while the envelope waits its turn), per-peer foreground-first scheduling (each peer has its own serialized chat/changeset-poll chain, so a slow transcript read cannot hold other peers; queued foreground envelopes or active-chat socket pressure defer background changesets for at most 2 seconds, after which only the smaller active-chat batch is admitted before returning to foreground work), mobile-chat event compaction (compactChatEventEnvelopeForSync, a thin envelope adapter overcompactChatEventForWireinapps/desktop/src/shared/chatEventCompaction.ts— the same policy the stored transcript uses, applied to live sends, snapshots, and replay entries while the desktop event remains unchanged; it bounds every heavy payload (command output, tool results, the provider's rawstructuredblob, file diffs, reasoning text, inlinedata:image/*URIs above 64 KB), retains original/omitted byte counts, and then dropstool_result.structuredandtool_result.toolResultMetafrom the wire entirely because no client decodes them — see chat → Persisted transcript), replay delta folding (foldChatEventEnvelopesForReplayinapps/desktop/src/shared/chatReplayFold.ts, applied to the fullchat_subscribesnapshot for peers declaringfoldedReplay: consecutive streamingtext/reasoningdeltas of one message collapse into a single event. Only provably-clean appends fold — desktopmergeStreamingTextand iOSmergeWorkStreamingTextalready disagree on overlapping or repeated deltas, so folding those would pick a winner and change what one client renders. A folded run sits at its first delta's position and carries the last delta's sequence, and delivery bookkeeping marks the pre-fold envelopes so the transcript pump cannot re-send a collapsed delta. The replay-buffer resume path is deliberately not folded: its per-eventseqmonotonicity drives the client'sseq <= lastSeqdrop rule), the mobile changeset diet (MOBILE_CHANGESET_EXCLUDED_TABLES: tables the phone never reads from a changeset —attempt_transcripts,operations,ai_usage_log,budget_usage_records,automation_runs,automation_action_results,pull_request_snapshots, the seven event logslinear_ingress_events,linear_sync_events,linear_workflow_run_events,worker_agent_runs,worker_agent_cost_events,pack_events, andcto_session_logs, and the cross-machine usage rollupsusage_machine_rollups/usage_machine_rollup_metaplus the deleted-lane aggregateslane_usage_tombstones, all three of which must replicate desktop-to-desktop but reach the phone throughusage.getAdeStatsinstead (the tombstone table is not even in the phone'sDatabaseBootstrap.sql, so iOS drops its rows on arrival) — are filtered from phone changesets while ack watermarks still advance; the event logs exist in the phone'sDatabaseBootstrap.sqlso an inbound row would still apply, but there is no Swift read path for any of them), compact reseeding for replica phones more than 5,000 versions behind (ACK- and chunk-capable iOS peers receive one bounded current-statecatchupbatch, then resume incremental delivery only after itschangeset_ack), the host-authoritative table filter (SYNC_HOST_AUTHORITATIVE_TABLES:sync_cluster_state— the CRR that governs brain ownership — never crosses the CRR boundary in either direction, so a peer can neither receive it nor author a winningcrsql_changesrow that would flipbrain_device_id; brain handover stays on the explicit host-transfer RPC), the host-authoritative column filter (HOST_AUTHORITATIVE_COLUMNS_BY_TABLE:terminal_sessions.settled_at/settle_override/settle_source, dropped from inbound changesets from phone peers only — see Host-authoritative columns), the inbound changeset-batch ceilings (MAX_INBOUND_CHANGESET_ROWS/_BYTES≈ 40× the outbound 250-row / 256 KB caps, i.e. ~10k rows / ~10 MB; an oversizedchangeset_batchis rejected with achangeset_too_largeack before anyapplyChangesso one giant batch cannot seize the DB inside itsBEGIN IMMEDIATEtransaction), the per-session chat-event seq- replay buffer, terminal/chat subscription bridging, offset-stamped
terminal streams, a bounded snapshot barrier that queues live data until an
authoritative transcript snapshot is captured,
sinceOffsetdelta snapshots, terminal scrollback paging viaterminal_history, subscribed chat scrollback viachat_history(authorized against the existing project/personal/foreign subscription, append-stable byte cursor, and retryableunavailableresponses), acknowledged/deduped terminal input with legacy fallback, mobile terminal input/resize forwarding into subscribed PTYs, desktop-size restore after the last phone detaches, lane presence decoration, project catalog/switch envelopes, runtime-scoped project action envelopes (browse/open/create/clone/ list GitHub repos/default parent directory/forget), project-id alias matching between the machine catalog id and the hosted DB id, per-IP pairing rate limiter, mobile compatibility advertisement (features.mobileCompatibilityderived from the shared required-action contract), and the Tailscale Serve / mDNS publication paths. Runtime kind is one ofdesktop-embedded,headless,remote-stdio,desktop,daemon, orremote. After a successful project command it records meaningful user mutations in the local usage ledger, derivingmobile/web/desktopattribution from the peer metadata; reads and failed commands do not create events. It also owns the all-projects session roster push for the mobile Hub: per subscribed peer it tracksrosterSubscribed/rosterSeq/ arosterBaselinemap, debounces rebuilds (trailing-edge with a hard cadence ceiling), and forces a coalesced flush after a remote command adds/removes a roster-visible lane or chat. The snapshot itself comes from an optional injectedSyncRosterProvider.buildSnapshot(); a host without one (single-project desktop) never answersroster_subscribe. It also takes an optionalforeignChatProvider(SyncForeignChatTranscriptResolver) that powers cross-project chat quick-look: achat_subscribenaming a registered foreign project is resolved to that project's on-disk transcript path and streamed read-only (byte-capped tail snapshot plus a disk-tailing live pump, tracked per peer inforeignChatTranscriptPaths) with no runtime boot; the presence of the provider is what flips the advertisedcrossProjectChathello feature flag. An injectedpersonalChatScopeadds the separatepersonalChatshello feature and resolves chat subscriptions that explicitly carrychatScope: "personal"; it never infers personal scope from a missing project id.
- replay buffer, terminal/chat subscription bridging, offset-stamped
terminal streams, a bounded snapshot barrier that queues live data until an
authoritative transcript snapshot is captured,
-
rosterBuilder.ts— builds the machine-wide all-projects session roster (SyncRosterProject[]) consumed by the Hub: agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions — live and ended. Identity-bound chats (including the per-project CTO) and their attached descendants are excluded from this ordinary roster; the optionalidentityKeywire marker remains only as a defensive signal for stale or legacy payloads. The roster is built only from projects whose registrycatalogVisibilityis"recent", plus the host's own project (matched byhostProjectId) which is always included even if it is a"system"-visibility entry — so the machine you are actively hosting never vanishes from its own Hub while runtime-auto system projects stay out of the feed. Opens each project's<root>/.ade/ade.dbread-only withnode:sqlite(no cr-sqlite, no runtime boot — the same cheap cross-project read pattern asrecentProjectSummary.ts) and merges cachedchat-sessions/*.json, so an all-projects feed never activates every project. Live running/awaiting status is overlaid only for scopes already booted on the runtime; a booted scope also overlays PTY liveness for CLI rows (they never appear inagentChatService, soptyService.hasLivePtyis what flips them torunning).attentionCount(which drives hub badges and attention-first project sorting) counts only chat rows and shells attached to a chat — a standalone CLI session that exited non-zero must not pin its project to the top forever, since mobile has no way to clear it.runningCountcounts chats whose status isrunningand that are not snoozed; a snoozed running chat is idle on Activity, so including it would disagree with the Hub tree and the island. Each chat carries optionalsnoozedUntil/snoozedAtso older hosts omit them and older phones ignore them. Previews are hard-truncated (~120 chars). Also exportscreateForeignChatTranscriptResolver({ projectRegistry })— the resolver behind cross-project chat quick-look and its security boundary: it maps a(registered foreign project, sessionId)pair to that session's transcript JSONL path, rejecting unsafe session ids and any path that would escape the project's.adetranscripts dir, and returning null for unknown projects (never booting a runtime).ade servewires it as the host'sforeignChatProvider. -
sharedSyncListener.ts— the brain-level WebSocket listener shared across per-project host services. Bind order always triesDEFAULT_SYNC_HOST_PORT(8787) first viabuildSyncHostPortCandidates, even when device registrylastPortis 8788 — a sticky fallback used to win and leave phones on a port they never saved. Preferred-port retry runs up to ~8 attempts over ~3.2 s on the first candidate before the rest of the scan, so a transient brain restart does not drift the port. Port diagnosis lives insyncListenerPortInspect.ts(lsof/pson POSIX, trusted PowerShell on Windows) and is re-exported forade doctor. Once a live holder is confirmed, the listener skips the remaining duplicate retries for that port and emits one conflict warning before advancing. WebSocket upgrades are accepted only on the sync root path (/). When an Origin header is present, it must name the canonical hosted web client (https://app.ade-app.dev) or one of the explicit local Vite origins; foreign origins are rejected and logged at debug. An absent Origin remains valid for non-browser clients such as iOS URLSession, ADE CLI peers, and the relay bridge, whose private bridge-proof header is validated separately. On anEADDRINUSEfor a port in the sync range (8787 throughSYNC_HOST_MAX_PORT8999) it runs sync-port zombie reaping: it diagnoses the port's holders, and if a stale ADE brain owns it, re-confirms the same pid + process start-time on a second diagnosis (guarding against pid reuse), terminates that holder, logssync_listener.zombie_reaped, and retries the freed port once. Reap exclusion is this process only — launchd's tracked main pid is often the wedged predecessor still bound to 8787; excluding it is how a replacement brain used to stick on 8788 with two listeners on one machine (dual-brain split). Cross-channel conflicts (ADE vs ADE Beta) are left alone; the "ADE is already running with phone sync" dialog is intentional. When already listening on a fallback port,tryMigrateToPort(8787)binds the canonical port alongside the current listener, closes the old one on success, and keeps the existing bind when 8787 is still busy (migrate probes are single-shot, not the 3.2 s initial-bind storm). The same diagnosis feeds theade doctorSync-port row, which is explicit that it cannot see a root-owned holder: a strandedtailscale serveentry from an earlier run holds the port throughtailscaled, so a user-level probe reports no holders even though the port is taken (tailscale serve statusandnetstat -an -p tcpshow it). Those leftovers are reclaimed on the next tailnet publish; doctor also notes that ADE retries 8787 first and migrates back when it frees. The listener is handed between hosts on project switch: the new host adopts the open sockets — peer metadata carried over, pairing auth re-validated against the pairing store, changeset cursors recomputed from the peer's per-site cursor map, chat/terminal/roster subscriptions and transcript offsets riding the handoff snapshot, and frames buffered during the handoff window replayed — so phones survive project switches without reconnecting. Sockets left unowned park with buffered frames and close with code 4002 after a 30 s grace. A machine-wide fallback handler may accept new sockets when no project host owns the listener, but it is suppressed during the handoff grace after a project host detaches so reconnecting phones still park for adoption by the next project host. A self-owned server path remains for tests/standalone hosts. The listener'shttp.Serveralso serves the streamed attachment-upload route: every plain HTTP request is offered togetAttachmentUploadRegistry().handleRequestfirst and falls through to the loopback 426 probe otherwise. There is one registry per listener instance, shared by every candidate bind and by every project host that adopts the listener, because a ticket is only redeemable by the request handler holding its map — a host-local registry would mint tickets no route recognises, and a ticket minted before a port migration is still honoured after it. -
attachmentUploadService.ts— that registry. The sync stack has no HTTP authentication of its own (before this route the listener answered exactly one request shape, an unconditional 426), so authorization is delegated to the already-authenticated WebSocket session: a peer that finishedhellomints a short-lived ticket over the command channel and presents it as an HTTP bearer onPOST /ade-attachments/upload. A ticket names one project root and one validated extension, expires in two minutes, and is consumed before the first body byte is read, so a replay cannot resume a partial upload.issueandhandleRequestboth prune expired tickets rather than running a timer, which would have to be unref'd to keep a short-lived CLI process from hanging on exit. The body streams to a.partfile and is renamed only after the full body lands, so a torn upload never leaves a half file at the final path for a chat to pick up; the running byte counter is the real ceiling, because a chunked upload declares noContent-Length. The destination name comes from the sharedstagedAttachmentDestPath, so this route and the local copy path cannot disagree about where a file just landed. -
syncListenerPortInspect.ts— platform port-holder diagnosis used by zombie reap andade doctor(inspectSyncListenerPort). Extracted fromsharedSyncListenerso the probe path can stay small and the listener module stays focused on bind/migrate/handoff. -
brainProjectActionsSyncHandler.ts— machine-wide fallback sync handler used byade servebefore any project host is active. It takes asecretsDir(not individual PIN/pairing file paths) and resolves its stores throughresolveBrainMachineSyncStores, so the RPC surface mutates the same instances it verifies against. It parses helloes with the sharedsyncHelloProtocoland runs account helloes through the sharedsyncAccountHelloAuthgate chain. Account helloes used to be refused here on the theory that only a project sync host owns the account session — which made "install, sign in, connect from anywhere" impossible until the user opened a project, since the web client authenticates this way and no other. They are now accepted with the project host's own gates. Connection arbitration and sealed adoption are options it leaves unset: anaccount_sealedhello needs anaccount_challengeround trip this handler does not serve, so it is answered with a plain "pair with a code, or open a project" underaccount_session_changedrather thanauth_failed, because the device's saved pairing is untouched by that refusal.connectionAttemptmetadata is now parsed rather than silently discarded. It authenticates the same PIN / paired-secret / bootstrap paths as the per-project host, applies the same failed-PIN cooldown, attributes itsauth_failedrejections with the samehost: { deviceId, name }identity the per-project host sends (so a phone that reaches this fallback over a stale address still won't destroy a pairing it can't attribute), and serves project catalog plus runtime-scoped project actions so a phone can add/open/create/clone/remove a project even from the project picker. It receives the samePersonalChatScope, advertises the same capability/action descriptors, and can execute personal commands before any project host is active. Personalchat_subscribesnapshots carry the same byte paging metadata as project-host snapshots, andchat_historypages continue to work through this fallback without booting a project host. It shares the project host's compression offer/selection and typed protocol mismatch behavior; the selected codec is stored per socket only after the legacy-encodedhello_okis accepted for send. It also answerscommandenvelopes: when no project host owns the peer (host restarting, or blocked by a conflicting sync listener) it replies immediately with acommand_resultcarryingerror.code: "host_unavailable"instead of silently dropping the command — a dropped command used to leave the phone staring at a 30 s timeout with a vague "took too long" banner. iOS treats that code as transient (retryable and queueable, like a timeout), so queued operations survive host restarts instead of being deleted on replay. -
syncHostSingleton.ts— the machine-wide sync host lease. Owns the advisory lock file ($TMPDIR/ade-sync-host-<uid>.json, overrideADE_SYNC_HOST_LOCK_PATH) that records the owning pid, channel, project root, and bound port, diagnoses conflicts (SyncHostSingletonConflictError) against live listeners so a stale record cannot strand a new host, and exposesupdatePort/disposeon the acquired lease. Alongside the file it keeps a process-wide authority registry:holdsSyncHostSingleton()answers whether this process currently owns the machine's phone sync, andonSyncHostSingletonAuthorityChanged()notifies subscribers on none-held → held and held → none-held transitions. That registry is what the relay tunnel and the account-directory publisher gate on — see The machine-wide sync host lease above. -
syncHostStartupLoop.ts— retry loop around mobile sync host startup for the brain. Same-channel singleton conflicts (update races, restart overlap, a stale sibling) always retry — the loop may evict a stale same-channel squatter, but only when this brain is the channel's installed runtime service child. A conflict with another channel's live brain is rethrown only on the first attempt, so brain startup can fail loudly with quit instructions; after the first attempt (the brain is already serving) cross-channel conflicts keep retrying on the slow cadence and auto-recover the moment the foreign owner exits, instead of permanently giving up and stranding paired phones on the ingress fallback.The loop also classifies what it caught rather than retrying everything the same way. Each failure goes through
classifyStorageFault(storage/storageErrnoClassifier.ts, shared with the database open — see storage and recovery). A classified storage fault always takes the slow 30 s cadence and never the 2 s fast ladder: an unreadable~/.adeis not going to be readable two seconds later, and retrying at that rate only spends CPU and fills the log. The human line quotes the classified sentence — the one naming the folder and the fix — instead of "Unknown system error -11".Three side channels hang off the classification, all guarded so telemetry can never become a second failure path.
logEventemits the structuredsync.host_start_failed(signature, attempt, code, errno, provider, message) andsync.host_start_recovered(attempts, last signature) through the same deduper as the human line, so both are throttled together instead of one flooding while the other stays quiet.recordStorageFaultwrites a machine-scoped last-failure record withcomponent: "sync_host", so the fault is in the diagnostic report even when nobody was watching. And aftersustainedStorageFaultAttempts(3) consecutive storage faults,onSustainedStorageFaultfires once per loop to send an automatic diagnostic report; the counter resets on any non-storage failure. That trigger lives here rather than on the account publisher for a structural reason explained under auto-send: the publisher does not exist until the sync host starts, so it could never report a sync host that would not. -
changesetPump.ts— batch-chunk selection for changeset fan-out. Splits an export intochangeset_batchenvelopes at ~256 KB / 250 rows while never splitting rows that share adb_version(the ack watermark is version-granular). -
mobileReplicaReseed.ts— bounded compact-state construction for an iOS replica strictly more than 5,000 versions behind. It scans at most 1,000 relevant rows per host poll, caps the shared cache and logical payload at 10,000 rows / 4 MiB, and falls back to normal incremental replay when the current state or one version group exceeds those limits. -
syncPeerService.ts(~580 lines) — WebSocket client. The runtime can run this too when it joins another runtime as a peer (handoff rehearsal, controller-to-authority swap). On iOS, an equivalent Swift implementation lives inapps/ios/ADE/Services/SyncService.swift. -
syncProtocol.ts— canonical Node envelope codec and protocol boundary. Owns the supported protocol range (SYNC_PROTOCOL_MIN_SUPPORTED...SYNC_PROTOCOL_VERSION), the typed mismatch error, close code4406,DEFAULT_SYNC_HOST_PORT(8787),SYNC_HOST_MAX_PORT(8999), andbuildSyncHostPortCandidates(preferredPort?)— bind order always puts 8787 first so a stickylastPortof 8788 cannot skip the canonical port phones and other computers keep saved. -
syncBinaryFrame.ts— binary envelope container ("ADE1" magic, u32 header length, header JSON, raw compressed body) plus the magic sniff that keeps a text frame delivered as binary data on the text path. It retains the legacy gzip threshold (DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024) for peers that omit compression negotiation, and supports negotiated zlib-wrappeddeflateatSYNC_APPLICATION_COMPRESSION_THRESHOLD_BYTES(512 bytes). Decode is capped atMAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES(25 MiB). Encoded envelopes aboveDEFAULT_SYNC_MAX_FRAME_BYTES(720 KiB) can be split intoenvelope_chunkframes; reassembly accepts at most eight chunk sets, 512 parts, a 128-bytechunkId, and 32 MiB total buffered data, and expires an incomplete set after 30 seconds. -
abortSignal.ts— the shared cancellation helper (runWithAbortSignal,abortSignalError) used across the sync command paths so a registration- or caller-carriedAbortSignalrejects in-flight work with a consistentAbortErrorinstead of each call site re-implementing the wiring. -
syncRemoteCommandService.ts(~4,600 lines) — command registry (lanes, chat, git, PR, sessions, conflicts, files,usage.getAdeStats,usage.getUsageRollup,usage.getQuotaSnapshot,usage.refreshQuota,prs.getMobileSnapshot,lanes.presence.*,work.runQuickCommand,work.startCliSession,work.listExternalSessions,work.importExternalSession,chat.recoverCodexTurn,chat.regenerateSessionMetadata,modelPicker.*, …).chat.createAttachmentUploadmints one ticket for the streamed HTTP upload route from the host's singleAttachmentUploadRegistry. It is how a mobile or browser sync client stages a file-shaped attachment; a paired desktop arrives instead through the runtime RPC channel, on the action registry'schat.createAttachmentUpload, which reaches the same registry throughsyncHostService.issueAttachmentUploadTicket. There is deliberately no remote "copy this path" command on this channel:chat.copyTempAttachmenttakes an unconstrained absolute source path, and the mobile command allowlist is the one place ADE draws that line, so the exclusion narrows what a phone can do rather than what a paired desktop can. The cross-machine handoff family (chat.prepareCrossMachineHandoff,chat.validateCrossMachineSource,chat.preflightCrossMachineDestination,chat.fastForwardCrossMachineHandoffLane,chat.acceptCrossMachineHandoff, andchat.markCrossMachineHandoff) keeps source and destination work inside their owning project runtimes. The fast-forward command lets a destination catch an existing clean lane up to the source commit with a--ff-onlymerge it re-validates itself, so a shared branch such asmainis not an automatic dead end. Final acceptance is not queueable; destination idempotency is keyed by the capsule's handoff id and fingerprint instead of relying on command replay. Desktop Send to machine reaches the destination through multi-project runtime JSON-RPC: paired routes carry that stream inrpc_dataenvelopes, while SSH usesade rpc --stdio. The sync host's remote-command responder timeout therefore does not bound the paired desktop acceptance action. Stalled-turn recovery is viewer-allowed but not queueable because it must target the currently active Codex turn. Mobile/remote Codex CLI launches also resolve the explicitly opted-in, verified standalone Computer Use MCP client and add it through the shared launch builder. Each registration carries aSyncRemoteCommandDescriptorwith a scope label of"runtime"or"project". The runtime rejects aproject-scoped command when no project is open or when the caller did not bundle a matchingprojectId(see Scope enforcement below). Mobile / controller CLI launches resolve the target lane worktree before building provider argv/env so Agent Skill roots andADE_AGENT_SKILLS_DIRSstay lane-aware. External-session imports share the desktop external-session service and DTOs: list returns provider summaries, while import returns the created ids plus the persistedTerminalSessionSummaryorAgentChatSessionSummary. Controllers install that summary before navigating, avoiding a race with replicated/session-list state. See External Session Import for the provider storage formats, host/runtime requirements, and mobile testing constraints.usage.getAdeStatsis a viewer-allowed project read backed by the runtime's usage tracker; it serves cached provider/GitHub data plus live DB aggregates to iOS and web without replicating the local-only raw interaction ledger. Itsscopeacceptsaccount,machine, orproject, andforce(set only by an explicit user Refresh) bypasses the account fan-out's rate floor.usage.getUsageRollupis the account scope's fan-out call: a viewer-allowed runtime read that returns this machine's day × provider × model aggregates and nothing else — no transcript record, session id, or path crosses the machine boundary. It is a separate command rather than a scope onusage.getAdeStatsbecause that one returns a full page payload the merger would mostly discard. Anullresult means "not ready yet" (the machine has not finished its first ledger scan); the caller records a retryable failure rather than storing an empty rollup, which its reconcile pass would read as history having been removed.usage.getQuotaSnapshotreads the cached live Claude/Codex limit snapshot, whileusage.refreshQuotaruns the bounded quota-only provider path with interactive auth disabled. Neither remote quota action starts local provider history scans or sends provider credentials to the controller. Lane snapshot commands accept decoration flags so mobile can refresh runtime/session buckets without recomputing conflict status, rebase suggestions, or auto-rebase status on every light refresh; lane detail uses the scoped lane-summary path instead of forcing a full lane list. Model-picker commands read/write the same per-project CRR-backed favorites/recents store as desktop and the TUI; the sync service falls back to the DB-wired shared store when no explicit accessor is injected, so iOS never reads an empty process stub in production. Lane reparent commands parse the optionalstackBaseBranchRefoverride and forward it to the runtime lane service so controllers can pick a specific branch to stack onto instead of always using the selected parent lane's branch. ThepersonalChats.*family is registered from the sharedPERSONAL_CHAT_ACTIONSallowlist withscope: "runtime"; the executor callsPersonalChatScopedirectly instead of looking up the current project. OnlypersonalChats.sendis queueable. A queued create is prohibited because it cannot return a stable optimistic session id and replay could duplicate a conversation.lanes.createcalls that omitbaseBranch/startPoint/parentLaneId(hub-composer auto-create, the mobile create sheet's default) resolve a remote-first default base on the host before creation: the project'sgit.newLaneBaseSourceconfig (effective default"remote") selects between a bounded remote fetch +origin/<primary base>mapping and the legacy local primary tip; the resolution helper isapps/desktop/src/shared/defaultRemoteLaneBase.ts(shared with the desktop create-lane dialog's renderer-side default). -
deviceRegistryService.ts(~670 lines) — synceddevicestable andsync_cluster_statesingleton. Peer app provenance —appVersion,appBuild,bundleIdentifier— carried onSyncPeerMetadata(parsed from thehellopayload insyncHostService/brainProjectActionsSyncHandler, where iOS populates them from itsBundle.maininfo dictionary) is persisted into each device'smetadata_jsonbag when the registry upserts the peer. When the local runtime joins another runtime as a viewer (syncService.connect), it wipes its existingdevicesandsync_cluster_staterows and then callsdb.sync.discardUnpublishedChangesForTables(["devices", "sync_cluster_state"])so the resulting CRR DELETE rows are suppressed from outbound changesets.syncService.connectthen callssyncPeerService.acknowledgeLocalDbVersion()to advance the outbound cursor past the suppressed range, ensuring a fresh viewer cannot accidentally erase the authority runtime's registry. Seecrdt-model.mdfor the underlying suppression mechanism. Local identity snapshots return cached/fallback values synchronously while macOSComputerName(scutil) and the Tailscale DNS name refresh asynchronously; Tailscale status is single-flight and retained for 30 seconds so periodic machine publication cannot block the brain event loop on an external CLI. The identity defaults are exported aslocalSyncDeviceDefaults()soprojectlessSyncSnapshotcan name the machine without a project database. The Tailscale probe spawns withwindowsHide, so a packaged Windows brain never flashes a console window on its 30-second cadence. -
syncPairingStore.ts— validatespairing_requestenvelopes againstsyncPinStore, mints the durable per-device secret, and persists it into thepaired_devicesrow (SQLite). EachSyncPairingRecordcarries its provenance:accountOwnerUserId(null for a QR/Nearby-PIN/SSH pairing made at the Mac) and the stickylocalTrustOriginflag set when such a record is later adopted into an account.pairPeerViaAccountmints or rotates from a verified same-account attestation and performs that adoption;isValidDpopPublicKeyis the shared validity test both this store andsyncHostServiceuse so a blank pinned key can never be mistaken for a real one.revokeAccountOwnedExceptis the sign-out / account-switch sweep: it deletes records owned by another account but demoteslocalTrustOriginrecords back toaccountOwnerUserId: null, and writes whenever it deleted or demoted. See Adopting a manual pairing into an account for the gate. -
syncPinStore.ts— on-disk storage for the user-set 6-digit pairing PIN at~/.ade/secrets/sync-pin.json, chmodded0600. The runtime never rotates the PIN; the operator sets or clears it from the This computer card in the Connections panel. -
resolveTailscaleCliPath.ts— Tailscale CLI discovery used for the tailnettailscale servepublication path. -
syncDpop.ts— device-bound pairing (DPoP) helpers: the canonical signing string builder,evaluatePairedHelloDpop(validates aSyncDpopProofagainst the stored P-256 public key and TOFU-adopts an offered key for legacy devices), andcreateSyncDpopNonceCache(a replay guard partitioned to 256 recent nonces per device with a 4,096-entry global ceiling). A device may evict only its own oldest entries; when the global ceiling has no safe slot, verification fails closed withnonce_cache_saturatedinstead of evicting another device's replay history. Shared bysyncHostServiceand the brain ingress handler. -
syncSecurityStore.ts— machine-level sync security posture stored at~/.ade/secrets/sync-security.json(chmod0600). Owns therequireDpopflag with theADE_SYNC_REQUIRE_DPOP=1|0env override; both the per-project host and the brain ingress handler read it. -
machineIdentitySigningStore.ts— the machine's long-lived Ed25519 identity keypair at~/.ade/secrets/machine-identity-signing.json(chmod0600, lazily generated, cached per file path, regenerated if corrupt). The public key is published as the directory row'spubkey; the private key signs theade-adopt-v1challenge so a client can verify it is talking to the machine it selected before releasing any account credential. Shared by the host service and the account-directory publisher. -
apps/desktop/src/shared/sync/adoptChannelCrypto.ts— the sharedade-adopt-v1primitives used on both sides of sealed account adoption: X25519 ephemeral key generation, the canonical challenge signature input, HKDF-SHA256 session-key derivation over the X25519 shared secret + nonce, AEADseal/unsealwith context-bound AAD, and Ed25519 sign/verify against the raw published key. Also imported bymachineIdentitySigningStore.tsfor SPKI↔raw key conversion. The AEAD is negotiated:ADOPT_CHANNEL_AEADSlistschacha20-poly1305thenaes-256-gcm, andsupportedAdoptChannelAeads()probes which of them the running crypto backend can actually construct (cached). This is what lets a packaged Electron whose bundled BoringSSL lacks ChaCha20-Poly1305 still account-connect: the client advertises the AEADs it supports in the challenge, the host picks the first it also supports, and the chosen AEAD is folded into the challenge signature input (aeadfield) so it cannot be downgraded by an on-path attacker. A client that sends no AEAD list, and both sides by default, fall back tochacha20-poly1305. -
syncCloudRelayStatus.ts—buildSyncCloudRelayStatus, the one projection of relay state that the desktop and the CLI read whether a project scope owns sync (syncService.ts) or the brain answers for the bare machine (brainMachineSyncStores.ts). Both surfaces had their own copy and they had already drifted in whitespace, one edit away from telling two different relay stories about one machine.accountSignedInis the gate: without it the live fields collapse to their off values andlastErrorbecomes the sign-in prompt, so a signed-out machine never reports a connection it cannot have. It is its own module rather than an export of the identity store because the two have nothing in common but a name — one reads a file of secrets, the other reshapes a status object — and the projection is imported by callers that have no business constructing a store. -
syncCloudRelayStore.ts— persists the cloud tunnel-relay identity at~/.ade/secrets/sync-cloud-relay.json(lazily-minted 32-hexmachineKey+ HMACsecret, chmod0600). Two rules govern every path through it, both bought by a production lockout in which a live MacBook became a stranger to its own account: an identity is never discarded while any copy on disk still holds it, and every mint, rotation, or recovery leaves onesync_cloud_relay.identity_rotatedline naming what changed and why. So the file is written throughwriteFileAtomic(0600 temp, fsync, rename, then a parent-directory fsync everywhere but Windows, which has no directory handle to flush — and never a pre-unlink, which would leave a concurrent reader looking at nothing) and mirrored to a.baksibling after the primary lands, so a reader that falls back finds the last identity actually in force. A parse failure is reported as a failure rather than as an empty object: conflating "this machine has no identity yet" with "this machine's identity is temporarily unreadable" is what minted a whole new machine out of one corrupt file. Resolution keeps the machine key from whichever copy still has one and pairs the secret with its own key; only a file pair that yields nothing at all may mint, and it is logged ascorrupt_file_remintrather thanfirst_mintso the roster phantom it may create is explainable. The identity is stable in normal operation. Only a claim endpoint response with the exact HTTP status409can trigger the tunnel client's recovery: the store serializes competing brains with an exclusive sibling lock, compare-and-swaps the expectedmachineKey, and mints a replacement key + secret — at most twice per rolling 24 hours (MAX_IDENTITY_ROTATIONS_PER_WINDOW). That budget lives in the file, not in a closure: an in-memory counter reset on every brain restart, so a crash loop could mint one new machine row per boot and bury the owner's roster in phantoms. A budget that cannot be parsed reads as spent, because forgiving an unreadable counter is the same failure mode as not persisting it. The file carries a second persisted allowance on the same terms —pairingAutoRepairs, three per rolling six hours, spent bymachinePairingAutoRecovery.ts— and up to fivepreviousMachineKeys, the keys this machine actually retired, soconfirmSupersededMachineKeyscan tell a directory confirming our rotation from one describing somebody else's device; confirmed keys are then forgotten. Generic network, auth, upgrade, and bridge failures never rotate identity. Legacyenabled/enabledSetByUserfields are accepted only long enough to rewrite the file without them; there is no stored enablement or user kill-switch. The store derives the controller-facingwss://<relay>/connect/<machineKey>URL and the canonical host/pipe HMAC signing strings shared with theapps/tunnel-relayworker. The claim and host requests also decide where the relay's Durable Object is placed: the worker derives a location hint from the requesting machine's geography so the object is created near the machine rather than near whichever request arrives first. Cloudflare honors a hint only at creation, so an existing machineKey keeps its original placement. -
relayTunnelAuthorityGate.ts— decides whether a runtime may run the machine's relay tunnel at all.createRelayTunnelAuthorityGatesubscribes toonSyncHostSingletonAuthorityChangedand starts the tunnel only while this process both hosts the brain-level shared listener and holds the machine-wide sync host lease; a loss of authority stops it afterSYNC_HOST_AUTHORITY_RELEASE_GRACE_MS(5 s) so an in-process project switch rides through. Every start re-attaches the host listener, becausestop()drops the reference and it is otherwise attached once per runtime — a gate-driven stop/start would otherwise come back with a live control socket and no bridge, rejecting phone connects with "host sync listener unavailable" until the brain restarted. Re-winning the lease also callsclearControlSuppression(), but only for a gate that has actually observed a release, so opening a second project cannot reset the eviction budget.dispose()detaches the subscription without stopping the tunnel: the client is machine-level and shared across project scopes, so a closing scope must not sever relay for the others.bootstrap.tsbuilds one gate per scope. -
syncTunnelClientService.ts— the brain-side tunnel client. When the machine has a current ADE account lease it keeps an outbound WebSocket registered with the relay worker (HMAC-signed host/pipe upgrades, exponential backoff with decorrelated jitter, a 1 s floor, and a 60 s cap) so controllers off the LAN/tailnet can dial the machine through the relay.computeBackoffMssamples from a window widened by the previous delay rather than resampling the same narrow exponential band, so two clients that collide once do not keep colliding at the same instant; the floor exists because full jitter from zero let rival processes both resample near-zero delays forever. The client runs only underrelayTunnelAuthorityGate— the runtime holding the machine-wide sync host lease. Connect and reconnect are single-flight: lease reconciliation does not close a still-valid connecting socket, and a transient token-refresh exception retains the current control route through the last known account-lease expiry. Sign-out, an explicit missing lease, account switch, or expiry closes the control socket and active pipes. The normal ADE sync hello/pairing then runs inside that pipe. TLS terminates at the relay, so the relay can inspect the handshake and subsequent sync traffic. Bridge validation is proactive:validateCurrentBridge()re-probes the loopback sync listener (matching port + identity nonce) whenever the control socket opens and whenever the shared listener reports a fresh loopback validation, serializing probes through the same validation queue used by inbound opens. The listener itself arrives throughattachHostListener(), whichrelayTunnelAuthorityGatecalls on every start from the runtime that owns the shared listener — not from the construction factory. The client is shared one-per-machine (getSharedSyncTunnelClientService, keyed bysync-cloud-relay.json) and is built by whichever runtime bootstraps first, which is regularly a scope with no listener at all (a headless one-shot, an embedded fallback), so anything captured at construction would answernullfor the life of the process and the bridge could never validate. Attaching supplies the port, loopback nonce, and relay bridge proof, subscribes theonLoopbackValidatedretry hook (whose failures logsync_tunnel.bridge_validation_failed), and validates immediately if the listener is already bound;stop()detaches it. This flipsrelayBridgeValidated— and therefore directory relay publication — true as soon as the listener is confirmed, so the earlier "bridge not validated against the sync port" state self-heals instead of waiting for an inbound client to open the first tunnel.openTunnelstill re-validates on every inbound open as defense in depth. Validation calls are strictly serialized, and the port plus loopback identity are checked again before pipe creation so a listener change cannot reuse a stale result. The control socket uses native WebSocket ping frames every 30 seconds with a 10-second pong deadline. Because a hibernated or wedged Durable Object can leave the Cloudflare edge answering those transport-level pings while the machine's control registration is already dead (a "zombie" control), a low-frequency application-level JSON keepalive runs alongside it: the client sends{t:"ping"}on the control everyCONTROL_JSON_PING_INTERVAL_MS(180 s, first ping after ~1 s) and expects a{t:"pong"}withinCONTROL_JSON_PONG_DEADLINE_MS(30 s). A miss on either liveness path terminates the socket and enters the guarded reconnect state machine; a JSON-keepalive miss additionally records arelay control unreachable at relay (zombie socket)failure, drops end-to-end verification, and logssync_tunnel.zombie_control_detected. Beyond liveness, the client verifies the relay path end-to-end withrunSelfProbe(): it dials the relay exactly like a ready-v2 controller (syncRelaySelfProbe.probeRelayEndToEnd) and only treats the route as verified once it seesaccepted+readyv2 back through its own bridge. The probe runs (debounced ~2 s) whenever the control reaches ready and whenever the local bridge re-validates, and its verdict —relayEndToEndVerifiedAt,relayEndToEndFailure,relayEndToEndRoundTripMson the status — is what the account-directory publisher additionally gates therelayendpoint on (seeaccountMachinePublisherService.ts), so a control that connects but whose bridge cannot actually round-trip stays unpublished. A probe failure terminates the control as a zombie; anatCapacityclose (relay code4503CLOSE_TOO_MANY, sent only after the machine's control is confirmed registered) is treated as liveness proof and renders no verdict, leaving prior verification/publication state untouched. Opens that fail validation, local-listener setup, or pipe setup send a bounded{t:"reject"}signal so the waiting controller closes immediately instead of hanging. A real pipe/local setup error or timeout is also a generation-scoped publication blocker until a fresh tunnel reachesready; cached loopback validation cannot clear it, ordinary cancelled candidate closes do not create it, and a failed secondary attempt cannot poison a route that already has a ready tunnel. Pipe/local application close codes and sanitized reasons are preserved across the bridge; other closes normalize to4000. Account loss clears validation and all sockets, while account switches force a clean control reconnect. A close withRELAY_CLOSE_CONTROL_REPLACED(4505) is handled as its own regime, not as a network failure: it can only mean another process registered the samemachineKey, so redialing on the network schedule just evicts the rival right back. The client retries on a fixedCONTROL_REPLACED_RETRY_BASE_MS(60 s) floor jittered upward only, at mostMAX_CONTROL_REPLACED_REATTEMPTS(3) times, then stops dialing entirely and reportscontrolSuppressedwith the actionable reason "Another ADE process owns the relay connection for this machine." Suppression is enforced at the dial itself as well as in the reconnect scheduler, because the once-a-second account-lease poll callsconnectControldirectly. Because the rival usually exits on its own, a stopped client re-arms once afterCONTROL_REPLACED_REARM_MS(10 minutes);clearControlSuppression()also re-arms immediately when this process (re)acquires the sync host lease, and a control socket that survives the ready-stability window resets the budget so a long-lived brain cannot accumulate its way to a permanent stop. Structured events aresync_tunnel.control_replaced,.control_replaced_stopped, and.control_suppression_cleared; one edge-triggeredade_relay_suppressedanalytics event (coarse attempt count +control_replacedcode, no URL,machineKey, or close reason) is captured per suppression episode. A409claim conflict is the other regime. The client asks the identity store to rotate, and when the store refuses because the persisted 24-hour budget is spent it latchesidentityRotationCappedon its status and reportsRELAY_IDENTITY_ROTATION_CAPPED_MESSAGE— "This computer needs to be reconnected to your ADE account." — in place of the rawclaim failed (409). That is not a retry state but a product state: another mint would be another phantom row on the owner's roster, and the row this machine already owns is the one that has to be repaired, which is the only thingclaim failed (409)never says.syncRouteHealth.tsranks that message above the raw close text for the same reason it rankscontrolSuppressedReasonabove it. The latch is cleared by the next successful claim and republished, so a repaired machine stops asking to be repaired without a restart. Control observability preserves the causal failure rather than replacing it with a generic WebSocket error: upgrade rejection captures the HTTP status and at most 512 sanitized response bytes; close telemetry records code, reason, and whether the socket opened.sync_tunnel.claimed,.claim_failed,.control_open,.control_error,.control_close,.self_probe_ok,.self_probe_failed,.self_probe_at_capacity, and.zombie_control_detectedare the structured lifecycle events.routeHealth.relayexposesskipReason/lastControlErrorplus the end-to-end verdict, whilelastControlOpenAtandlastBridgeValidationAtretain the two independent success histories. It also carriesrelayControlSuppressed,relayControlSuppressedReason, andrelayControlFailingSinceMs— the last being the start of the current uninterrupted outage, whichlastFailureAtcan never express because it restamps on every retry. Suppression outranks every other reason in both theade doctorrelay row and the desktop banner, since nothing downstream can succeed while another process owns the slot and no other reason tells the user what to do about it. -
syncRelaySelfProbe.ts—probeRelayEndToEnd, the stateless relay round-trip check used by the tunnel client'srunSelfProbe. It openswss://<relay>/connect/<machineKey>?ready=2, requires anacceptedv2 first frame then areadyv2 second frame within a 15 s timeout, and reports{ ok, roundTripMs }on success. A close beforereadyis a failure, but a4503CLOSE_TOO_MANYclose (the relay's at-capacity signal, sent only for a registered control) is flaggedatCapacityso the caller treats it as liveness proof rather than a zombie/failure. Timeout and cancellation cleanup keeps the probe's error handler installed while terminating a still-connecting WebSocket becausewsemits that termination error asynchronously. The failure is therefore contained in the probe result and cannot escape as an uncaught exception that terminates the machine runtime. -
headlessProjectLaneCount.ts—headlessProjectLaneCount(rootPath), the one lane count both the project-open path and the machine's project catalog use.laneService.listcounts rows without checking the worktree still exists, so a project with one deleted lane reported N on open and N−1 in the catalog — a visible flicker on the phone and in the browser's recents. The catalog path fills counts in only for projects whose root is actually present on this machine, skipping disk work for roots that are not. -
relayAuthorization.ts— lease renewal for already-authenticated Relay peers. A capable controller refreshes before expiry with a DPoP signature bound to the exact token bytes, device id, host challenge, timestamp, and nonce. The host checks account owner/generation on both sides of remote verification and accepts only a later token expiry with at least 30 seconds remaining. Expired tokens and verifier outages are retryable; account/key/ proof mismatches close the peer. Successful nonce receipts are bounded and included in socket-handoff state so retrying a response lost during project handoff is idempotent. Legacy peers that do not advertise renewal close at token expiry; capable peers have only the advertised short grace window.
Account Activity and push:
apps/ade-cli/src/services/push/pushPublisherService.ts— owns the publish lifecycle for one bounded machine contribution across every project hosted by the brain: run/PR/session-removal tracking, the protocol-2 publish, the signed-out/degraded machine-snapshot fallback, and the durable machine-revoked gate (getMachineRevocation/clearMachineRevocation) that a removed machine latches so it stops delivering across restarts. The 30 s heartbeat rebuilds the roster and skips the write viaactivityRosterFingerprintwhen nothing moved; after four unchanged rebuilds the rebuild backs off to at most every two minutes while presence posts stay on cadence.refreshChatRunMetare-reads a live chat run's session summary on one bounded 10 s cadence (CHAT_META_REFRESH_MS, stamped aschatMetaCheckedAt) for its title as well as its planning mode — neither is announced on the chat event stream, and a first resolution that latched left every renamed chat wearing its birth title. First read and refresh share oneapplyChatSummary. Anapproval_requestis classified with the sharedisQuestionKind: a question publishes aswaiting_for_inputwith no Approve/Deny notification category, everything else (including an event with norequestKind) stayswaiting_for_approval.apps/ade-cli/src/services/push/attentionItemBuilder.ts— the Activity projection itself, lifted out of the publisher's closure so it can be exercised with a plain context record instead of a booted publisher.(runs, recentRuns, prActivities, roster) → AttentionItem[]: identity-chat and child-shell filtering, phase derivation (including holding a completed turn atrunningwhile background subagents live, and demoting a snoozed running chat tostale/idleunless it is failed or needs-you), the title/preview tables, the 2 h / 24 h / 7-day lifetimes, andattentionProjectRef. Theneeds_youprivacy preview reads "An ADE agent needs you." — the same two words the status label, the title suffix, and the notch's section heading use.apps/ade-cli/src/services/push/pushRegistrationStore.ts— durable device, delivery, machine-revocation, and machine-acknowledgment state. Machine acknowledgments are keyed by account owner + item and remain pending until a later successful account publish can reconcile them.apps/ade-cli/src/services/push/pushRelayClient.ts— authenticated relay client with one safe forced token refresh after a 401 and account-owner fences across asynchronous requests.apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts— desktop account-first read/ack/presence/preferences coordinator. It bypasses the selected project or remote-machine binding and uses the local machine runtime only as an explicitly labeled fallback. It chunks bulk acknowledgments at 64 through the sharedrunAcknowledgmentChunksand returns the three-wayacknowledged/stale/unreachedoutcome.apps/desktop/src/main/services/attention/remoteProjectIdentity.ts— reconciles the two project-id spaces an Activity click-through crosses. Root paths are compared with rules taken from the path's own shape rather thanprocess.platform, because the path belongs to a machine that may not run this OS; a case-folded match is accepted only when unambiguous.apps/desktop/src/main/services/attention/attentionOpenErrors.ts— turns a failed pair / connect / open into one sentence the user can act on, keeping the raw error ascause. The signed-out and unreachable patterns are deliberately narrow: a baresessionor401match sent users to fix an account that was fine.apps/desktop/src/main/services/deeplinks/localProjectResolution.tsandprojectNavigationWindowSelection.ts— resolving a link's project against this machine (exact id → carried root path → recomputed canonical id) and choosing which window may host it. A remote project never rebinds the focused window.apps/ade-cli/src/services/push/activityFingerprint.ts— the two identities every item carries. The content fingerprint is what the row looks like with elapsed durations and token/file counters normalized away, so progress churn does not rewrite account state; the alert fingerprint is the stable identity of one phase entry, so a re-published item cannot re-alert a phone that already heard about it.chatActivityModeis in the content fingerprint (it is a visible distinction) and deliberately out of the alert fingerprint, because planning and working flip several times a turn and neither flip is a new phase worth notifying about.activityRosterFingerprinthashes the selected and overflow ids plus those per-item publish fingerprints so a heartbeat can skip the D1 write when the roster did not move; itemrevisionis excluded because it is a republish timestamp.apps/desktop/src/shared/types/attention.ts— cross-client item, snapshot, destination, availability, preference, and native-presentation contract.ATTENTION_CONTRACT_VERSIONis the item contract; the publish protocol version is separate (seepush-notifications.md). It also owns the acknowledgment mechanics every shell shares:ATTENTION_ACKNOWLEDGMENT_BATCH_LIMIT(64, derived from the relay's own bound),chunkAttentionAcknowledgmentItemIds,runAcknowledgmentChunks(with the abort-on-first-failing-chunk policy), andAttentionAcknowledgmentOutcome.apps/desktop/src/shared/attention/activityStateGroup.cases.json— the cross-language conformance fixture for the six-group state table. The mapping is implemented four times (renderer TypeScript, native notch Swift, iOS Swift, and the hermetic relay Worker) because the surfaces cannot share code, and documentation alone did not keep them in step. Every implementation runs these cases through its own mapper. Canonical source of truth:activityStateGroupinapps/desktop/src/renderer/components/activity/activityPresentation.ts. There are six groups, not five:idlewas split out ofdonebecause a session that went quiet mid-work is not a session that finished.apps/desktop/src/shared/activityCatalog.ts— one table naming every Activity event: its group (agents / pull requests), its icon key, and its default delivery policy. Desktop settings, the Activity columns, and the delivery defaults read this instead of each keeping a private switch.apps/desktop/src/renderer/state/activityStore.ts— the renderer's account snapshot. Mutations are fenced on the loaded account owner and, per item, on the alert fingerprint the user actually had on screen; the old source-revision fence is gone, because revision advances on every publish and a live agent outran any poll. Rollback follows the three-way outcome:staleandunreachedeach roll back only their own rows, with different copy.apps/desktop/src/renderer/components/activity/useActivitySync.ts— the single account poller, mounted inAppShellso the header control and ADE Notch stay truthful while/activityis closed. It also derives the notch toast stream.apps/desktop/src/renderer/components/activity/HeaderActivityControl.tsx— the global-header count (theneeds-yougroup and nothing else) and its popover preview, which shows every state section except the two resting bands (idleanddone).apps/desktop/src/renderer/components/activity/ActivityPane.tsx— the/activitytwo-column pane, withActivitySessionsColumn.tsx(the agent feed, one section per state group, split per machine and divided where an offline machine's rows become last-known state),ActivityInboxColumn.tsx(the Notifications column: PR/CI and review outcomes grouped by project),ActivityFilters.tsx(machine / project / chat type / model, plus a single-select state-group glyph strip whose counts come from the unfiltered snapshot), andActivityDetailSheet.tsx.apps/desktop/src/renderer/components/activity/ActivitySectionHeader.tsx,activitySectionCollapse.ts,ActivityStateGlyphMark.tsx,ActivityAllClear.tsx, anduseAllClearBeat.ts— the shared section header (the whole strip is the button, with the<h3>/<h4>outline preserved for screen readers), per-surface collapsed-section memory, the Phosphor half of the state glyph language, and the all-clear beat that fires on the transition to zero raised hands and never on arrival.apps/desktop/src/renderer/components/activity/ActivityCard.tsxandActivityCardSkeleton.tsx— the row and its fixed-height placeholder. The card deliberately does not reuseterminals/SessionCard: an Activity row frequently belongs to another machine, andSessionCard's settle/snooze controls call this Mac's local session service, where a non-unique session id could land the mutation on a same-id local session. The status vocabulary is shared instead through the pureterminals/SessionStatusLabel.tsx, extracted fromSessionStatusSlotfor exactly this reason. Read the comment at the top ofActivityCard.tsxbefore "simplifying" it.apps/desktop/src/renderer/components/activity/activityPresentation.ts— the canonical state glyph language:ActivityStateGroup,ACTIVITY_STATE_GLYPHS,ACTIVITY_STATE_GROUPS(also the priority order),activityStateGroup, plus the per-item label/tone/glyph derivation and the detail sheet'sactivityStateSentence/activityStateElapsed. Change the rule here first; the notch, iOS, and relay mirrors follow.apps/desktop/src/renderer/components/activity/activityPriority.ts— the projection every surface reads:activityFeedItems(agents only),activitySections(one per state group, empties included),activityNotificationItems(non-agent, inbox-eligible),activityFeedOrder(what the notch mirrors), and the counts/leading-group helpers that replaced four hand-written priority ladders.apps/desktop/src/renderer/components/activity/useProgressiveRows.ts— the bounded row budget (60, stepped by 60) that keeps long columns cheap.apps/desktop/src/renderer/components/activity/activityNotchLocalSettings.ts— this Mac's offline cache of the notch presentation. Account preferences win when loaded. The three originalade:attention:notch-*localStorage keys are frozen wire for anyone who already made a choice; new settings got new keys.apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx— the gear in both the popover and the pane. It mountssettings/ActivitySettingsControls.tsxin itspopovervariant, whichsettings/ActivitySection.tsxalso mounts, so the Settings tab and the in-surface gear cannot drift. Every row saves on change; there is no Save button, which the popover it replaced did have.apps/desktop/src/renderer/lib/legacyRoutes.ts—LEGACY_ROUTE_ALIASESmaps/attentionto/activity. ADE's shell matches top-level surfaces with pathname predicates rather than<Route>elements, so there is no router-level redirect to hang a rename on; this is the route-level twin ofsettingsManifest.ts'sLEGACY_TAB_ALIASES.apps/desktop/src/renderer/webclient/adapter/attention.ts— direct browser account-relay reader plus signed-out paired-host fallback throughattention.getMachineSnapshot/attention.acknowledgeMachine.apps/ade-cli/src/tuiClient/activityPane.tsandcomponents/ActivityPaneView.tsx— ADE Code's machine-global/activitypane and exact-destination acknowledgment flow. The hidden/attentionalias andattention.callRPC remain for compatibility.apps/push-relay/src/attention.tsandattentionAuth.ts— account merge, Clerk verification, acknowledgments, presence/preferences, APNs fan-out, machine removal/revocation and pairing restore, and the cron-only expiry and orphaned-machine sweeps.apps/push-relay/src/liveActivity.ts— the account-wide Live Activity projection (state-group tally, roster caps, privacy redaction, start lease, and the per-device APNs start/update/end loop), split out ofattention.ts.apps/push-relay/src/attentionShared.ts— the environment, bounds, and helper vocabulary both of those need, existing to break the import cycle the split would otherwise create. It is where the relay declareschatActivityModeon its parsed item.apps/ade-cli/src/services/account/machinePairingRepair.ts— re-pairing this machine after removal. It owns the order the two halves lift in: the directory publish first, the durable push gate only after the directory accepts, because a machine back on the roster but silently undelivering is worse than one that is plainly gone.
See push-notifications.md for the full topology and delivery policy.
Desktop client adapter (apps/desktop/src/main/services/sync/):
Every file in this directory is a one-line re-export of the canonical
ade-cli module, e.g. syncHostService.ts reads export * from "../../../../../ade-cli/src/services/sync/syncHostService";. They exist
so the desktop's internal imports keep resolving while the canonical
implementation lives in the ADE runtime. The legacy in-process host
path in apps/desktop/src/main/main.ts (gated by
ADE_ENABLE_DESKTOP_SYNC_HOST=1) calls these re-exports and runs an
embedded runtime inside the Electron main process — kept only for
diagnostics. The unit tests next to the proxies still exercise the same
canonical code through the re-export.
Sync IPC routing in the renderer
(apps/desktop/src/preload/preload.ts): project-scoped
window.ade.sync.* calls
goes through callProjectRuntimeSyncOr(method, params, localFallback),
which:
- Resolves the active project binding. If the window is bound to a
remote runtime, the call goes over
IPC.remoteRuntimeCallSyncto the remote runtime. - Otherwise, it calls
IPC.localRuntimeCallSyncagainst the local runtime. In-process sync IPC is used only when no runtime binding is available, such as tests or diagnostics.
window.ade.sync.getLocalStatus(args?: SyncGetStatusArgs) is the deliberate
exception. Preload invokes ade.sync.getLocalStatus directly; main dispatches
sync.getStatus through the machine-level LocalRuntimeConnectionPool, never
the active window's remote project binding, with only the local in-process
diagnostics service as its unavailable-runtime fallback. This is the path the
Connections This computer projection and local pairing/device controls should use.
Renderers reach it only through renderer/lib/localSyncStatusReader.ts, the one
shared reader. The sync-status broadcast is an invalidation, not a payload,
so every subscriber re-reads the local snapshot when an event lands. That is
free while the local runtime answers in milliseconds and pathological once it
does not: a measured perf run recorded 64k getLocalStatus calls in seven
minutes, 71% of them expiring at the 30s IPC timeout — a backlog of stacked
in-flight invokes, not a poll. The shared reader fixes that with two properties
and no change to the healthy cadence:
- Coalescing — concurrent readers share one in-flight invoke, so N subscribers reacting to one broadcast cost one IPC.
- Backoff — after an unhealthy read, further reads are served from the last known answer until the window elapses (1s → doubling → 30s cap). A healthy read resets it immediately.
"Unhealthy" is stated by main, not inferred: when the local runtime cannot
answer, buildMachineOnlySyncSnapshot sets degradedReason on the synthesized
snapshot. Renderers must key on that rather than on route health, which is
legitimately all-down on a healthy standalone machine that hosts nothing. Main
also collapses the accompanying sync.local_status_degraded log to once a
minute per distinct reason, clearing the latch on the first recovered read.
Reads a person asked for pass { force: true } and bypass the backoff window:
opening Connections, Try again, the read after a device mutation, and the
re-read after a brain Repair. Broadcast- and interval-driven refreshes stay
unforced, so a sick runtime is still only probed at the backoff rate. Because a
forced read can run alongside the shared one and finish first (the older shared
read may be sitting at the 30s timeout), the reader settles
last-issued-wins, not last-to-finish.
During project transitions, mutating sync methods (sync.setPin,
sync.clearPin, sync.connectToBrain, lane-presence updates, model-picker
favorites/recents writes, and similar state changes) fail with the same
"Project is switching" guard used by project runtime actions. Read/status
calls can still refresh after the new binding is established. Remote sync calls
replay only for the explicit retry-safe allowlist (status/discovery/device/PIN reads,
lane-presence announce, and model-picker reads); other sync mutations surface
connection errors rather than being replayed after reconnect.
Runtime-event IPC uses the same local/remote binding path. RemoteRuntimeStreamEventsResult
includes eventEpoch, gap, and oldestCursor; preload resets its cursor and
dedupe cache on epoch changes, and when a poll/subscription reports gap: true
it triggers the normal project-binding refresh path so renderer projections
recover from an evicted replay window instead of assuming the cursor was exact.
sync.connectToBrain is a legacy API name. New docs should call this a
runtime connection or sync authority connection.
The shared protocol DTOs (SyncEnvelope, controller-originated
terminal_input / terminal_resize, the mobile CLI launcher payload —
SyncCliLaunchProvider, SyncStartCliSessionArgs,
SyncStartCliSessionResult — the external session aliases
SyncListExternalSessionsArgs / SyncImportExternalSessionArgs, and the
runtime-scoped PersonalChatRemoteCommandActions) live in
apps/desktop/src/shared/types/sync.ts. The CLI launcher's
provider-to-argv translation is shared with the desktop Work tab
through apps/desktop/src/shared/cliLaunch.ts.
Hosted-web wire client (apps/desktop/src/renderer/webclient/sync/):
wireProtocol.ts— browser envelope encode/decode throughCompressionStream/DecompressionStream, negotiateddeflate, outbound chunk framing, bounded inbound chunk reassembly with the same 30-second expiry, and strict integer protocol-range validation.connection.ts— advertisesdeflateandchunkedEnvelopesinhello, applies the host'shello_okselections to later sends, serializes async compression/chunk writes to preserve envelope order, clears partial chunks on disconnect, and surfaces protocol mismatch as a terminal update error.
iOS service files (apps/ios/ADE/Services/):
Database.swift— native SQLite3 + pure-SQL CRR emulation (triggers- custom SQLite functions). Offline caches for files workspaces,
directory listings, file contents, session pin/runtime state, chat
snapshots, PR mobile snapshot persistence, the CRR-safe
pull_request_chat_sessionsrelationship table, and integration proposal fields mirrored from desktop schema.
- custom SQLite functions). Offline caches for files workspaces,
directory listings, file contents, session pin/runtime state, chat
snapshots, PR mobile snapshot persistence, the CRR-safe
SyncService.swift— WebSocket client, legacy gzip plus negotiated zlib-wrapped deflate envelope encoding, bidirectional bounded chunk framing and 30-second reassembly expiry, typed protocol-range mismatch presentation, command routing, keychain integration, PIN-based pairing, the sealedade-adopt-v1account-adoption client (challenge/verify against the directorypubkey, sealedaccount_sealedhello, and LAN → Tailscale → Relay route fallback with per-stage progress and a PIN-pairing fallback), lane presence announcements, terminal subscribe/unsubscribe tracking, terminal input/resize senders, mobile CLI launch/continuation, external-session list/import commands for Work, PR mobile snapshot fetch (including active/previous lane PR projection), live chat-event push listener, subscription-scopedchat_historyrequest/response tracking with an 8-second non-disconnecting timeout and legacy command fallback, lane reparent payload building with the optional stack base-branch override, project hub/catalog state, active-project scoping, and local project-list hiding for "Remove from list" so cached DB rows and runtime catalog rows for the same root disappear together.SyncConnectionRace.swift— the single happy-eyeballs race that dials direct and Relay candidates together: candidate plan construction, stagger / concurrency / overall budget, the Relay join delay and the separateaccepted/readydeadlines, the coarse network fingerprint (wired,wifi:<own IPv4 /24>,cell) and MRU-capped per-network route memory, per-endpoint failure memory, and the single-flight registry that prevents two concurrent/connectdials for one machine.SyncRecoveryPolicy.swift— deterministic reconnect, request-timeout, and heartbeat-silence policy, plus the roam-trigger policy: a real interface-set change (failover) or a periodic upgrade probe toward a strictly better transport class are the only reasons a healthy connection is re-raced.KeychainService.swift— iOS Keychain Services for paired device secrets (per-machine token shelf included).
iOS widget files (under apps/ios/):
ADE/App/DeepLinkRouter.swift.ADEWidgets/ADELockScreenWidget.swift.ADE/Shared/ADESharedModels.swift,ADE/Models/RemoteModels.swift,ADE/Resources/DatabaseBootstrap.sql(generated from desktopkvDb.ts).
Multi-project runtimes and project switching
The machine runtime knows every project the user has opened on that machine (within retention) and exposes them as a single catalog. The mobile transport is one brain-level WebSocket listener on a stable port; one project's host service owns the connected peers at a time. The phone pairs with the machine once, sees the catalog, and stays on the same port across project switches. Desktop SSH remote recents are not part of this phone catalog: the catalog is local to the paired machine/runtime, so remote-machine paths are filtered out before mobile summaries are built. The phone flow:
- Phone connects and sends
hello. The runtime responds withhello_okcontaining the current project catalog (when supported). - The phone renders the catalog as a project hub — recent projects
marked available/cached/unavailable, with
MobileProjectSummarymetadata (icon, lane snippets) supplied by the runtime. - The user taps a project → phone sends
project_switch_request. The runtime'sprepareProjectConnectiononly opens the target project scope and replies with the current port in aproject_switch_result(freshconnectionpayload orconnection: null, meaning reuse existing pairing credentials). - After the result is flushed,
completeProjectConnectionruns: the old host stops first and the new host starts on the same port under the 8787-first preferred-port retry, adopting any sockets that stayed open. A phone that initiated the switch tears down and reconnects against the same port; a phone that was merely connected while another client switched projects is adopted in place and never disconnects. If the switch fails, the previous host is restored so the listener is never left unowned.
The hosted browser uses the same machine catalog and project-switch protocol behind a different shell. Its permanent Hub chooses a machine, while the top bar persists logical repository tabs. Same-origin checkouts on different machines share one repository tab and expose machine choice inside that tab. Changing tabs reconnects a Parked machine if necessary, switches the runtime to the bound project, and installs the adapter keyed to that exact binding. Only the active hosted project surface mounts; inactive tabs retain navigation and binding metadata rather than live renderer subscriptions.
The project hub can also manage machine projects without first binding
to a project DB. project_browse_request,
project_default_parent_dir_request, project_open_request,
project_create_request, project_clone_request,
project_list_my_github_repos_request, and project_forget_request
are runtime-scoped envelopes.
When a project host is active, syncHostService handles them; when no
project host owns the shared listener, brainProjectActionsSyncHandler
handles the same envelopes so the phone can add a first project or
remove stale recents on a headless or freshly-started machine. On the
phone, removal also stores host-scoped local hidden keys by project id
and normalised root path so a cached DB row and a remote catalog row for
the same project do not reappear until the user opens/selects that
project again.
Project catalog snapshots are also chunked
(MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 KB,
maxProjectCatalogChunkBytes = 192 KB) so a runtime with many projects
streams the catalog in project_catalog_chunk envelopes.
To make the next switch land faster, ProjectScopeRegistry.prewarmRecentScopes
does a one-shot background warm-up of at most two most-recently-used project
scopes after startup. Prewarming calls get(projectId, { touch: false }) so it
never rewrites registry recency, excludes the already-warm active host, and is
suppressed while a sync-host switch is in flight (syncHostTransitionDepth > 0)
or after disposal; a failed prewarm is swallowed so a later real open retries
get() normally and surfaces its own error. The sync-host switch itself is now
wrapped in a syncHostTransitionDepth guard so overlapping prepares/switches
don't race the prewarm or each other.
Scope enforcement
syncRemoteCommandService.register(action, policy, handler, scope)
labels every command as "runtime" (machine-wide; doesn't need a
project binding) or "project" (must run inside an open project).
At dispatch time:
- If the command is
project-scoped and the runtime has ahostProjectIdbut the caller did not includerequestedProjectId, the runtime rejects the command with"requires projectId"(code: missing_project). - If the runtime was opened from the machine project registry with one id
and the project DB already contains a different persisted project id,
the host accepts either id as an alias for the same open project. This
keeps older mobile caches and DB-scoped command payloads from being
misrouted as
project_not_open. - If the command is
project-scoped and the runtime has no project open, the runtime rejects it with"requires an open project on this ADE machine"(code: project_not_open).
A phone bound to a runtime-hosted catalog therefore must complete the
project_switch handshake before invoking project-scoped commands.
Device registry and cluster state
A synced devices table keyed on device_id carries durable device
metadata. Fields (see SyncDeviceRecord):
| Field | Purpose |
|---|---|
device_id | Unique device identifier |
site_id | Stable cr-sqlite site id |
name | User-assigned device name |
platform | macOS, iOS, linux, windows, unknown |
device_type | desktop, phone, vps, browser, unknown (browser is the hosted web client — see ../web-client/README.md) |
created_at / updated_at / last_seen_at | Timestamps |
last_host / last_port | Last manual-connect address |
tailscale_ip | Tailscale IP if available |
ip_addresses (JSON array) | LAN IPs |
metadata_json | Future-safe extension bag; holds dbVersion plus peer app provenance (appVersion, appBuild, bundleIdentifier) when the peer advertised them in hello |
Sync authority is separate: sync_cluster_state is a singleton row
keyed on cluster_id = "default" with brain_device_id,
brain_epoch, updated_at, updated_by_device_id.
Sync authority selection and transfer
Sync authority transfer is an explicit runtime operation; no client elects itself automatically. Only one runtime owns execution at a time. Phones are controller-only and never elect themselves.
Transfer:
- Preflight blockers — running chat turns and live PTYs. CTO history/idle threads and idle/ended chats are treated as durable synced state and survive a handoff.
- Final sync flush on the old authority runtime.
sync_cluster_state.brain_device_idrewrites,brain_epochincrements.- New authority runtime starts its sync lifecycle. Old authority runtime demotes.
A second desktop that simply pulls the repo without joining a sync cluster is its own local ADE machine for execution — that is not the same as being part of the cluster. Multi-runtime active-active execution is not supported.
Account directory and connection leases
The account directory is discovery, not durable liveness authority. The
runtime publishes every 30 seconds into a 90-second directory presence lease;
the online bit expires when those heartbeats stop, but a previously validated
secure Relay endpoint is still worth an authenticated dial. Machine-selection
surfaces therefore distinguish "no current heartbeat" from "no usable endpoint" and
keep a row connectable while at least one directory-verified secure route
remains. The authenticated hello is the final availability and identity check.
Sleep is a stated fact, not an inference from silence. A closed laptop lid
and a healthy laptop look identical from the directory's side — both stop
heartbeating, and last_seen_at is equally recent for a few minutes either way
— which is how a phone kept reporting "Connected" to an unconscious Mac.
Migration 0006_machine_power.sql adds three nullable columns to machines:
power— JSON{"batteryPercent": 0-100 | null, "charging": bool | null, "onExternalPower": bool | null}.batteryPercentis null, never0, on a machine with no battery.sleep_state—'awake' | 'asleep', as last announced by the machine itself.sleep_state_at— epoch ms at whichsleep_statelast changed, deliberately distinct fromlast_seen_at.
All three are optional on the wire and the register upsert coalesces rather
than overwrites them, so an old host heartbeating alongside a new one cannot
blank what the new one stored, and one dropped field cannot erase known state.
Power is advisory: a malformed value degrades to unknown and never rejects the
machine registration. Because sleep_state coalesces forward it has no path
back to NULL, which is why clients age the announcement rather than trusting it
forever (resolveMachinePresence, above).
The announcement happens in the beat before the machine goes dark. On a
desktop host, Electron's suspend event reaches machinePowerBrainBridge,
which calls the brain's machine.reportPowerTransition RPC (cto role
required; kind: "suspend" | "resume", optional budgetMs clamped to at least
250 ms) inside a 2-second budget; the brain notes the announced suspend and
awaits one bounded publishPowerStateNow() HTTPS write, coalesced so a single
suspend produces a single write. On resume the brain notes the wake and lets
the publisher's own subscription push an immediate "awake" rather than waiting
out the 30-second heartbeat. A host with no wiring for this (a headless brain,
a Linux box) answers {accepted: false, reason: "unsupported"} and falls back
to the heartbeat-gap detector, which infers the same transition with no
platform-specific code.
Directory list, delete, rename, and publish operations retry one 401 with a
forced access-token refresh. Only a repeated 401/403 is classified as
auth_expired; timeouts, server failures, and temporary token-verifier/JWKS
failures remain retryable and do not erase pairing trust. Rename is an
owner-scoped PATCH /account/machines/:machineKey with an additive, nullable
customName capped at 80 characters. Registration updates the reported
hostname and reachability lease but never overwrites that custom name. Clients
preserve both values and use customName, then the reported hostname, as the
display precedence.
One computer, one row
A directory row is keyed (user_id, machine_key), and the machine key lives in
a file. Anything that replaces that file — a reinstall, a wiped config
directory, a restored backup, a relay claim conflict this machine recovered
from — therefore produces a second row for one physical computer. The owner
sees two, deletes the one that looks stale, and half the time that is the live
install. Three mechanisms, each in a different layer, keep that from happening:
- The key is hard to lose.
sync-cloud-relay.jsonis written durably and mirrored to a.baksibling, an unreadable file is distinguished from an absent one, and a machine key is preserved from whichever copy still holds it. A new identity is minted only when both copies yield nothing. - Rotations are budgeted and the budget is persisted. Two per rolling 24 hours, counted in the identity file itself so a crash loop cannot mint one row per boot. A machine that spends the budget stops minting and says so — "This computer needs to be reconnected to your ADE account" — instead of retrying.
- The directory dedups what still gets through. A register call that carries
proof of a fresh interactive sign-in (or spends a pairing grant) and whose
deviceIdorhardwareIdmatches other rows on the account retires those rows, folds the most recent user-typedcustom_nameonto the survivor, and returns the retired keys.hardwareIdis what covers the casedeviceIdcannot: both the machine key and the device id live under~/.ade, so a full wipe mints both afresh and matches nothing, while a per-account hash of an OS-level machine identifier survives it. It is salted with the account id and folded with the ADE home path, so it can neither correlate two accounts nor merge a Beta install into Stable's row, and a host that cannot read one simply omits it.
Getting back on after a refusal
Removal is deliberately durable: the directory records a revocation before deleting the row, so a removed machine that still holds a valid account token cannot simply re-register itself. Getting back on needs a credential a removed machine cannot mint — either an access token whose interactive authentication happened within the last ten minutes, or the single-use pairing grant minted at the end of a device-flow sign-in. Spending a grant is two-phase (reserve, then consume or release) so a relay outage during the hand-off no longer burns the one credential a reinstalled machine had.
That repair no longer requires a human. machinePairingAutoRecovery runs the
same brain action the Reconnect this computer button runs, on a slow
budgeted schedule (1 minute, 5 minutes, then hourly; three repairs per rolling
six hours, persisted), for the two refusal codes and for a publish leg wedged in
snapshot_failed. It widens nothing — a genuine removal is refused exactly as
it would be interactively — and it holds off entirely while a revocation is less
than ten minutes old, which is the same window in which the directory would
still accept the machine's existing sign-in. Waiting that window out means the
only repair this loop can land is one granted on stale-but-valid grounds: a
stale row, a key rotation, a directory hiccup. A deliberate removal stands, and
recovering from it needs the user's next interactive sign-in.
Every refusal the Worker issues is also logged with its wire code, a finer
reason, the correlation id, and 8-character identifier prefixes, because by
the time a locked-out user asks for help the request itself is long gone.
Account adoption captures the account owner/session generation and rechecks it before and after credential persistence so a late result cannot recreate trust after sign-out or an account switch. Once the host has minted a device-bound paired secret, that host-issued direct trust is distinct from the account session that found the machine: sign-out removes directory visibility and Relay authorization but does not delete the secret needed for LAN/Tailscale reconnect. Forgetting the machine is the explicit trust-deletion boundary. Signing into a different account cannot use the previous account's directory or Relay lease.
Hosted web exposes both trust scopes without conflating them. Removing a
machine from the ADE account deletes the owner-scoped directory row. Forgetting
it on this browser deletes only the local environment, paired secret, and DPoP
material; an account machine can appear again from the directory and be
adopted later. Account rename updates customName and leaves browser trust
unchanged.
Relay has two related but distinct leases. The machine's control tunnel may
survive a transient refresh failure only until its last known account-token
expiry; sign-out, owner change, or expiry closes the control and active pipes.
Each paired Relay peer also carries its own short-lived account authorization.
Peers advertising relayReauthorizeV1 renew that authorization in place with
a DPoP-bound fresh token; terminal identity/proof failures close the peer,
while expiry/verifier-unavailable results can retry inside the advertised
grace. Older peers close exactly when their initial token expires.
Adopting a manual pairing into an account
A device can hold a pairing record that predates the account: SyncPairingRecord
with accountOwnerUserId: null is a QR, Nearby/PIN, or SSH pairing made by hand
at the Mac. When that same deviceId presents an account-authenticated hello,
the host adopts the record instead of leaving it local: it sets the account
owner, mints a fresh device-bound secret, and returns it in accountPairing.
This is what lets a signed-in device that no longer holds its manual secret
recover over the network rather than requiring a physical trip back to the Mac.
Adoption is an authorization decision and it is gated on evidence, not on the caller's claim:
- The hello must carry a DPoP proof that verifies against the P-256 key already
pinned on that record. The pinned key is the only evidence that the
signing-in device is the same physical device that paired manually, so a
record with no valid pinned key is refused outright (
sync_hostlogssync_host.account_existing_keyless_rejected). Both the host guard andsyncPairingStore.writeNewPairingRecordtest key validity rather than truthiness viaisValidDpopPublicKey, so a blank or whitespace-only field cannot slip past one guard and land inevaluatePairedHelloDpop's legacy TOFU branch, where the proof would be checked against a caller-supplied key. - The Clerk attestation must be verified and re-captured under the commit lock, and a record already owned by a different account is still refused.
- Unlike first-time adoption, the pinned key and
createdAtare preserved; the hello's offered key is ignored.
Adoption is deferred while a PIN re-pair is staged on the record and the
device has not acknowledged it (pairingStore.hasPendingRotation). Adoption
writes through rather than staging, which would discard the staged secret and
leave the device's pairing_commit with nothing to promote. That hello answers
hello_ok without accountPairing and logs
sync_host.account_adoption_deferred_pending_rotation; a device mid-re-pair
holds a working secret by definition, and every client treats an omitted
accountPairing as "keep the credential you already have".
Adoption grants the account a way to use a pairing; it does not rewrite who
created it. SyncPairingRecord.localTrustOrigin records that the underlying
trust started as the user's own physical act at the Mac, and is sticky once set.
The sign-out / account-switch sweep (revokeAccountOwnedExcept) therefore does
not delete such a record — it demotes it back to accountOwnerUserId: null
so it returns to pure local trust and stays usable on LAN/Tailscale. Demotion,
not merely skipping the delete, is what keeps it usable: every reconnect path
rejects a record whose owner no longer matches the signed-in account, so a
surviving-but-stale owner is the same dead end in a different place. A later
same-account hello re-adopts the demoted record through the same gate. A
successful adoption logs sync_host.account_legacy_pairing_upgraded.
Device discovery
- Machine-to-machine: pair or connect from Connections > Machines with a same-account machine, Nearby/network discovery + PIN, or Advanced SSH. Account machines appear automatically after sign-in and adopt without a PIN; Nearby is the only direct PIN-pairing entry point. There is no Share/pairing link or manual address + PIN surface. The saved result is a per-device DPoP-bound secret; legacy machine bootstrap tokens remain internal compatibility state, not a user pairing path.
- Project switch handoff carries auth.
SyncProjectConnectionPayloaddistinguishesauthKind: "bootstrap" | "paired"and may carry apairedDeviceIdinstead of a rawtoken. When a phone follows a desktop project switch,prepareProjectConnectionreturns the payload,completeProjectConnectionruns after the runtime has acknowledged the switch, and the iOS client falls back to its per-machine saved token (keyed by machine identity / route / name inKeychainService.tokenAccount) when the desktop did not bundle a fresh credential. - Phone pairing: user-set 6-digit PIN stored on the runtime at
~/.ade/secrets/sync-pin.jsonas a PBKDF2 hash. The PIN is owned by the human operator — the runtime does not rotate it, does not time-expire it, and does not mint a one-shot code. The runtime keeps plaintext only in the current process after the user sets/generates it (or after legacy migration), so after a restart the host can verify pairings with the existing digits if the user still knows them. It cannot display or copy those digits until the user generates or sets a new PIN. The This computer card in Connections exposes the generate-new-PIN recovery path; the phone enters the same digits shown there after scanning the QR or choosing the machine from Nearby. ADE account sign-in is the primary PIN-less phone path through the directory and Relay. Failed PIN attempts increment a per-IP counter; after 5 failures the runtime rejects further attempts from that IP for 10 minutes (PAIR_FAILURE_THRESHOLD = 5,PAIR_COOLDOWN_MS = 10 * 60_000insyncHostService.ts). - QR payload:
SyncPairingQrPayloadis version 3, encoded as a single smart pairing URL (https://ade-app.dev/pair#<base64url(JSON)>, codec inapps/desktop/src/shared/pairingQr.ts). This URL is internal wire encoding for the system camera / App Clip path, not a user-facing link to copy or paste. The payload rides the URL fragment, so the JSON never reaches a web server. It carries machine identity, port, and address candidates (plus the cloud-relayrelayUrlwhen the host is signed in) — it never embeds a pairing code or expiry, so the phone still needs the PIN manually. It may also carry an additive optionalpinConfiguredBoolean (PairingQrPayloadinapps/desktop/src/shared/pairingQr.ts, mirrored byPairingQrPayload.swift): a hint that the host already has a pairing PIN set, so the scanner can steer a no-PIN host toward the generate-a-PIN step instead of a dead-end PIN prompt. The hint is advisory only — the livepairing_result(pin_not_set) stays authoritative if the PIN changes after the QR was minted. Newer payload versions parse leniently: the iOS scanner accepts any version ≥ 3 as long as the fields it understands are present, and both codecs treat a non-BooleanpinConfiguredas absent. - Address candidates: the runtime advertises LAN IPs, the saved
lastHost, the Tailscale IP,127.0.0.1, and — while the host has a current ADE account lease — arelay-kind candidate carrying a fullwss://…/connect/<machineKey>URL.SyncAddressCandidateKindislan | saved | tailscale | loopback | relay, but the savedlastHostis now emitted with the kind it actually is:buildAddressCandidatesclassifies it aslanwhen it matches a current LAN IP,tailscalewhen it matches the Tailscale IP or DNS name, and only falls back to the opaquesavedkind for a host that no longer matches the live address set. This is what lets the account directory publish a LAN-backed saved host as a real LAN endpoint. iOS treats Relay as one more authenticated candidate in the same happy-eyeballs race as the direct LAN/Tailscale routes — ranked behind them, but not gated on their exhaustion (see the transport race inios-companion.md). Already-paired phones also learn the relay URL fromhello_ok/brain_status(cloudRelayWssUrl) and persist it with the host profile for reconnects. - mDNS:
publishLanDiscoverybuilds a TXT record whoseaddressesCSV includes the Tailscale IP alongside LAN IPs. It also advertisesruntimeKind,runtimeVersion,projects, andprojectCount, so mobile can show a machine-first picker before it hydrates the full project catalog over the paired WebSocket. The runtime keeps a signature of{ hostName, port, txt }and re-publishes the announcement only when the signature changes, to avoid churn while IP addresses fluctuate. On macOS the runtime also forks adns-sd -R <serviceName> _ade-sync._tcp local <port> ...child (publishNativeLanDiscovery) so the native mDNSResponder advertises the service alongside the Node-sidebonjour-serviceregistration — iOS Bonjour browsers see the machine even when the userland advertiser is throttled. The native child is killed on shutdown (stopNativeLanDiscovery). On startup the runtime also runsparseNativeLanDiscoveryProcessListto detect orphaneddns-sd -Rprocesses from a previous ADE session that crashed without cleanup, and kills them before starting its own advertisement. - Machine-scoped pairing state: phone pairing files live under the
machine ADE home (
~/.ade/secrets/):sync-device-id,sync-bootstrap-token,sync-pin.json, andsync-paired-devices.json. On upgrade, legacy per-project copies under<project>/.ade/secrets/are copied or merged into the machine store, with paired devices deduped bydeviceId. - Tailscale Serve tailnet discovery: when the runtime sees a usable
tailscaleCLI (viaADE_TAILSCALE_CLIor the macOS default/Applications/Tailscale.app/Contents/MacOS/Tailscale), it runs a plain per-nodetailscale serveagainst the live sync port (targettcp://127.0.0.1:<port>); the tagged-nodesvc:ade-syncService form is not used because it requires tagged nodes and pinned a constant port that never matched the live socket. Status flows out throughSyncRoleSnapshot.tailnetDiscovery(SyncTailnetDiscoveryStatus:disabled | publishing | published | pending_approval | unavailable | failed) pluserror/stderrtails. The runtime tracks atailnetServeSignature(serve:<port>) so re-publishing is a no-op while the port hasn't changed. Becauseserve --bgoutlives the process that registered it, each successful publish is followed by a best-effort reclaim (staleAdeTailnetServePorts+reclaimStaleTailnetServes, logged assync_host.tailnet_serve_reclaimed):tailscale serve status --jsonis scanned for ADE's exact signature — a port in the sync range forwarding to127.0.0.1on the same port — and every match other than the live one is turned off. Without it, a restart or force-kill strands an entry that Tailscale keeps bound on the tailnet address, ADE's next wildcard bind failsEADDRINUSEagainst its own leftover and walks one port higher, and the port ratchets upward on every start. A hand-rolledtailscale serveforwarding anywhere else is never touched, and the live port is re-checked inside the loop because reclaiming frees exactly the low ports a concurrently restarting host prefers.
Sync protocol (summary)
Envelopes are JSON with fields:
{
version: number, // integer inside MIN_SUPPORTED...CURRENT
type: "hello" | "hello_ok" | "hello_error" | "pairing_request" |
"pairing_result" |
"account_challenge" | "account_challenge_ok" |
"account_challenge_error" |
"changeset_batch" | "changeset_ack" |
"heartbeat" | "file_request" | "file_response" |
"terminal_subscribe" | "terminal_unsubscribe" |
"terminal_snapshot" | "terminal_data" | "terminal_exit" |
"terminal_input" | "terminal_resize" | "terminal_history" |
"chat_subscribe" | "chat_unsubscribe" | "chat_event" |
"roster_subscribe" | "roster_unsubscribe" |
"roster_snapshot" | "roster_delta" |
"brain_status" |
"project_catalog_request" | "project_catalog" |
"project_catalog_chunk" |
"project_switch_request" | "project_switch_result" |
"command" | "command_ack" | "command_result" |
"rpc_open" | "rpc_data" | "rpc_close" |
"fwd_open" | "fwd_data" | "fwd_close" |
"envelope_chunk",
projectId?: string | null, // present on project-scoped envelopes
requestId: string | null,
compression: "none" | "gzip" | "deflate",
payloadEncoding: "json" | "base64",
payload: ...,
uncompressedBytes?: number, // gzip/deflate only
}
Envelope types and hello_ok.features keys remain additive inside the
supported protocol interval
SYNC_PROTOCOL_MIN_SUPPORTED...SYNC_PROTOCOL_VERSION (currently 1...1).
Receivers decode the common envelope first and dispatch only the types they
implement; an otherwise valid unknown type is ignored rather than closing the
connection. This is how iOS and hosted-web clients safely coexist with the
desktop-only rpc_* and fwd_* extensions. The paired desktop treats missing
features.rpcChannel or features.portForward exactly like false and does
not attempt that channel, while legacy phone/browser clients continue on their
existing mobile command surface when those keys are absent or present.
An integer version below the floor or above the current version is different
from an additive unknown type. The host sends an uncompressed
hello_error with code: "protocol_version_mismatch",
receivedVersion, minSupportedVersion, currentVersion, and
updateTarget: "client" | "host", then closes with code 4406. The
project host and machine-level fallback use the same response path. Browser and
iOS clients treat the error as terminal and name the side that needs an update
instead of silently dropping or retrying the connection. Non-integer versions
remain malformed envelopes.
hello_error codes are the contract; the message is not
A rejected handshake carries a structured code, and clients must branch on it.
The message beside it is prose for a human and may be reworded at any time, so
pattern-matching it is a defect. SyncHelloErrorPayload (in
apps/desktop/src/shared/types/sync.ts) currently defines:
| Code | What it means | What the client should do |
|---|---|---|
repair_required | The host has no usable pairing record for this device. | Pair again. This is the one rejection the user can act on directly. |
auth_failed | The older, generic form of the same thing. | Treat exactly as repair_required. |
account_not_signed_in | The target computer is not signed in to an ADE account. | Sign in to the same ADE account on that computer. Never destroy a saved pairing. |
account_verification_failed | The target computer could not verify its ADE account session. | Check ADE's account state on that computer, then retry. Never destroy a saved pairing. |
host_update_required | The host cannot verify ADE accounts yet. | Update ADE on that machine. Never destroy a saved pairing. |
account_session_changed | The host's account session moved under the handshake, or the ingress cannot finish this sign-in shape. | Sign in / retry. Never destroy a saved pairing. |
relay_account_required | The route needs an account-authenticated hello. | Sign in on this device. |
connection_attempt_superseded | Another route won the same attempt. | Nothing is wrong; drop this attempt quietly. |
invalid_hello | The payload was malformed. | Client bug or version skew. |
protocol_version_mismatch | Version floor/ceiling, as above. | Update the side named by updateTarget. |
account_not_signed_in, account_verification_failed,
host_update_required, and account_session_changed exist because
auth_failed reads as "pair again" on every client, and that is the wrong — and
destructive — instruction for a target whose account is unavailable, whose
session cannot be verified, whose host is too old, or whose session moved
mid-handshake. Where a rejection can legitimately lead a client to drop a
saved pairing, the host also attributes itself with
hello_error.host: { deviceId, name }, and the client only acts when that
identity matches the pairing it holds.
Application compression is negotiated in the authenticated handshake. A new
iOS or hosted-web client offers an ordered hello.compression list; the host
selects the first mutual codec and returns
hello_ok.compression: { codec, thresholdBytes }. The current common codec is
zlib-wrapped deflate, and the selected threshold is 512 payload bytes. The
hello and selection-bearing hello_ok themselves retain legacy encoding;
both directions switch only after hello_ok is sent. If the offer is absent or
has no overlap, the selection is omitted and every implementation keeps its
pre-negotiation behavior byte-for-byte: Node/iOS legacy paths use gzip above
their existing 4 KiB threshold, while hosted web continues to send plain JSON.
There is no static dictionary and no zstd dependency.
parseSyncEnvelope accepts legacy gzip and negotiated deflate, caps decoded
output at MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES (25 MiB), rejects a declared
oversize before decompression, verifies uncompressedBytes, and rejects a
mismatch between compression and payloadEncoding.
Encoded envelopes larger than 720 KiB (DEFAULT_SYNC_MAX_FRAME_BYTES) are
sliced into envelope_chunk frames (base64 parts keyed by
chunkId/index) only after the client declared chunkedEnvelopes and the
host confirmed hello_ok.features.chunkedEnvelopes. That confirmation makes
the framing bidirectional: the host, iOS, and hosted web can all split outbound
envelopes, and each receiver reassembles the original encoded envelope before
normal decompression/JSON decode. A peer that does not advertise the capability
still receives and sends the exact single-frame legacy traffic.
Reassembly is bounded independently of the socket receive limit: at most eight
concurrent chunk sets, 512 parts per set, 128 UTF-8 bytes per chunkId, and
32 MiB aggregate decoded buffering. An incomplete set expires after 30 seconds
on Node, hosted web, and iOS; disconnect/reset clears it immediately. iOS also
keeps its WebSocket maximumMessageSize at 32 MiB. This protects large chat /
terminal snapshots, file_response, and command_result payloads without
letting abandoned chunk sets retain memory indefinitely.
Chat attachments ride HTTP, not the envelope stream
A chat attachment large enough to matter should not travel as base64 inside a command payload: it inflates by a third, is buffered in memory on both ends, and lands under the same 25 MiB decoded-envelope cap and 720 KiB framing as every other message. So a host that can accept a file as a file says so, and a client that can send one that way does.
The advertisement is hello_ok.features.attachmentUploadV1, an additive
capability carrying { enabled: true, path, maxBytes }. The host emits it only
when its remote command service actually registered
chat.createAttachmentUpload — the route is useless without the mint — so an
injected or custom command service that omits the command also omits the
advertisement rather than promising an endpoint nothing serves.
Using it is two legs, in this order and only this order:
- Mint over the authenticated socket.
chat.createAttachmentUpload(sync command channel, for phones and browsers) or the action registry'schat.createAttachmentUpload(runtime RPC channel, for a paired desktop) returns{ ticket, path, maxBytes, expiresAtMs }. Both reach the host's one ticket registry. - POST the body.
POST <origin><path>withAuthorization: Bearer <ticket>and the raw bytes asapplication/octet-stream. The origin is the sync endpoint withws:/wss:swapped forhttp:/https:— the sync HTTP server and the WebSocket share one port. The response is{ path }, the staged absolute path on the host.
The HTTP leg carries no standing authority: it never sees the pairing secret, the ticket is consumed before a single body byte is read, and an unspent ticket expires on its own after two minutes. The ticket names the route path and the capability names the origin; a client composes them rather than trusting either alone.
Three cases fall back to chat.saveTempAttachment with the legacy
image-only 10 MB contract, and all three are ordinary outcomes rather than
errors: a host predating the capability, a relay-routed connection (the
relay brokers WebSocket frames and cannot forward an HTTP POST to the host's own
listener — hello_ok.connectionTransport is the host's own statement of which
it is), and an SSH target, which is not a paired sync transport at all. iOS
stays on the legacy path and is not offered the route; workChatInputAttachmentMaxBytes
in WorkChatAttachmentTray.swift mirrors the legacy constant for that reason.
Transport readiness and path truth
Relay controllers negotiate bridge readiness before sending an ADE envelope.
They first open wss://…/connect/<machineKey>?ready=2. A current Worker sends
{"t":"accepted","v":2} immediately, then sends
{"t":"ready","v":2} only after the runtime control pipe and validated
loopback listener are both open. ADE hello is forbidden before ready.
If no accepted arrives within the short negotiation window, the controller
abandons that socket and retries the same route on a fresh URL without the
ready parameter for an old Worker. It never sends a legacy hello on the
ready-v2 socket: a delayed accepted would reinterpret that hello as illegal
pre-ready data. Once accepted arrives there is no downgrade; the attempt
waits for ready within the overall authenticated-hello budget or fails.
The Worker and brain bind control, pipe, ready, and reject messages to a random connection epoch. A replacement control supersedes the old epoch, so a stale pipe cannot attach to a new controller. Legacy Workers retain only a bounded pre-ready buffer; ready-v2 paths buffer no ADE data before the bridge exists.
iOS races authenticated candidates rather than declaring victory at TCP or
WebSocket open: attempts are staggered by 250 ms, limited to three concurrent
candidates and a 10-second overall budget, and the first successful
hello_ok wins. Every candidate in one race carries the same monotonic
peer.connectionAttempt id/start time. The host serializes commits for that
device and rejects a late loser as connection_attempt_superseded, preventing
a slower route from evicting the winner. Finally, hello_ok.connectionTransport
is the host-observed direct | relay truth after authentication; controllers
use it for diagnostics/policy rather than inferring the path solely from a
cached candidate label.
The hello_error union includes the pairing, account, protocol, and route
codes in the table above. An auth_failed payload also carries an optional
host: { deviceId, name } naming the machine that rejected the hello — both
the project host and the brain-level fallback handler send it. This is the
client's only safe basis for destroying a saved pairing: a phone drops its
credentials only when the rejecting host.deviceId matches the paired
machine's identity. An unattributed rejection (older host, or a stranger
machine reached over a reused DHCP lease / mDNS alias / stale Tailscale
candidate) keeps the pairing and the client moves on to other routes.
SyncPairingResultPayload.error.code is one of
invalid_pin | pin_not_set | pairing_failed.
Heartbeat interval is 60 seconds. Desktop peers close after two
consecutive missed heartbeats; mobile peers get a wider grace window
(MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6) because iOS can briefly suspend
foreground networking during app and route transitions. Reconnection
resumes from a per-host-DB cursor: hello_ok carries the host
DB's serverDbSiteId, the phone keys its inbound cursor by that site
(remoteDbVersionBySite) and sends the full map in hello, and the
host picks its own site's entry (falling back to the legacy single
cursor for older clients). Each hosted project DB has its own
db_version sequence, so the per-site map is what keeps a brain that
switches hosted projects from replaying everything or skipping
backlog. Runtime-side batching keeps every row for a given db_version
in the same changeset_batch; otherwise an ack for a partial
transaction would advance the receiver past unsent rows.
changeset_batch envelopes carry a batchId; legacy batches without
one are decoded with a deterministic fallback so older desktops can
still sync. The receiver replies with a changeset_ack once
applyChanges commits (or with an error code on failure). The runtime and
phone keep outbound batches pending until the ack lands, retransmitting
on timeout so a dropped wifi blip cannot lose a batch. After six failed sends
or acknowledgements, the sender abandons only that encoded batch — it does
not advance the last-acknowledged cursor. It backs off, rebuilds from the
same fromDbVersion with progressively smaller row/byte windows, and resets
normal limits only after a successful ack. Host and desktop-peer recovery
bottom out at 16 rows / 16 KB with 250 ms–4 s backoff; iOS starts at 64 rows /
64 KB, shrinks to one row / 4 KB, and backs off up to 30 seconds. A single
db_version transaction may exceed a target, because it is never split.
pendingChangesetPeerCount is surfaced through brain_status for
diagnostics; brain_status is a legacy envelope name.
An iOS replica advertising both changesetAck and chunkedEnvelopes takes a
compact path when its host cursor is strictly more than 5,000 versions behind:
the host sends one logical reason: "catchup" batch containing the bounded
current CRR state, split into envelope_chunk transport frames when necessary.
Its cursor still advances only after the batch ACK; later writes use ordinary
incremental batches. Oversized compact state falls back to the bounded replay
above. Local diagnostics use sync_host.mobile_replica_reseed_started,
_ready, _skipped, _sent, and _fallback; these polling mechanics are not
product analytics.
Mobile-originated command envelopes are deduplicated through a
short-lived mobileCommandResultCache (TTL 30 minutes, 512 entries)
plus a persisted journal, so a phone that retries the same
commandId after a reconnect receives the cached command_ack /
command_result instead of double-executing the action. Persisted
results are intentionally narrow: work.runQuickCommand and
work.startCliSession keep only the returned sessionId / ptyId
(and the TerminalSessionSummary for CLI launches), while failed
commands store a generic failure message instead of the original
payload.
Sub-protocols at a glance
| Sub-protocol | Purpose | Used by |
|---|---|---|
| Changeset sync | Bidirectional cr-sqlite row exchange. Normal delivery uses bounded 250-row / 256 KB incremental batches; an ACK- and chunk-capable iOS replica strictly more than 5,000 versions behind may instead receive one ACK-gated compact current-state reseed (10,000 rows / 4 MiB maximum) before incremental delivery resumes | All devices |
| File access | On-demand project/worktree file reads, listings, writes | iOS Files, desktop remote viewing |
| Terminal stream/control | Subscribe to a logical-offset transcript snapshot plus live PTY output. The host installs a snapshot barrier before capture, queues concurrent data/exit events (256 events / 2 MB), trims overlap at UTF-8 boundaries, and recaptures up to four times when the snapshot did not reach the queued watermark; it closes instead of flushing a gap or unreconstructable overflow. Web/iOS clients drop duplicate ranges, trim overlap, and issue one guarded sinceOffset recovery subscribe when a live chunk starts beyond their watermark. A delta appends only the missing suffix; a full snapshot is authoritative replacement even when its end equals the current watermark. ACK-capable input uses stable inputIds and a bounded host dedupe ledger so reconnect/timeout retry cannot type twice; legacy hosts receive one-shot input with no ambiguous retry. Viewport resize remains subscription-scoped and the last desktop size is restored after the last mobile viewer detaches | iOS Work tab, hosted web Work terminal |
| Chat stream | Agent chat transcript events plus subscribed byte-cursor scrollback. Each chat_event carries a host-assigned per-session monotonic seq backed by a capped replay buffer (500 events / 2 MB per session). The host carries sequence high-water marks through shared-listener rehydration and seeds a recreated buffer from the agent event sequence persisted in session metadata/transcript state, so it never reuses a (sessionId, seq) pair. The field remains optional and old clients keep working unchanged. chat_subscribe accepts sinceSeq: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to an authoritative snapshot. Optional live sends are marked delivered only after the WebSocket accepts the frame; a backpressured peer keeps its transcript offset in place and the pump stops at the first failed event so later chunks cannot overtake the missing one. A per-session hydration barrier blocks both the live broadcaster and transcript pump while a snapshot is captured. The pump resumes after the ack from the logical byte offset recorded before capture, so appends racing a slow snapshot arrive after the ack without a gap; snapshot overlap is removed by the normal delivery-key dedupe. The snapshot is a byte-capped tail: chat_subscribe also carries the client's maxBytes, and the host clamps the snapshot's getChatEventHistory budget to min(host cap, maxBytes) — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Modern acks also return cursorKind: "byte", tailStartOffset, and authoritative hasOlderHistory. A host advertising chatHistoryPaging accepts chat_history only for an already-subscribed session and matching project/personal/foreign scope; it reads the same authorized transcript path without switching projects or booting a runtime. Transient failures return unavailable: true and preserve the requested cursor. Snapshot and older-page transcript reads use asynchronous filesystem/zlib work; same-session tail reads coalesce, while archived gzip inflations are globally admitted with only the active inflate and newest queued destination retained. Small archives use a bounded memory cache; a larger archive is inflated at most once into an unlinked, process-private temporary file under a 256 MiB logical-size/LRU budget and a temporary-volume free-space guard, after which pages are random-access disk reads. Request cancellation propagates through queued work, file reads, and inflates, so disconnected clients cannot leave expensive transcript jobs running. Both event-history paging and the legacy chat.getTranscript route use append-stable logical byte cursors; the latter advertises cursorKind: "byte" so clients do not treat an offset as a dense entry index. Hosted-web and iOS older pages are capped at 256 KiB and a failed read preserves its byte cursor for retry. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries turnActive from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's status: started event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint). The additive foreign-scope protocol remains available to controller reads, but iOS Hub taps activate the owning project before opening the chat. A session_meta_updated chat_event carrying a client's permission/interaction/mode change also rides this stream, so a mode switch made on one client (desktop ↔ iOS) patches every subscribed client's cached summary and composer controls live without a refetch | iOS Work tab, iOS Hub, controller chat |
| Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live and ended — so the mobile Hub renders every project's sessions at once without activating each project. Identity-bound chats (including each project's CTO) and all attached descendants are excluded from this ordinary roster; the optional identityKey marker lets clients reject stale or legacy leaked rows. roster_subscribe (handshake mirrors chat_subscribe, with an optional sinceSeq) → roster_snapshot then incremental roster_delta (changed upserts whole project entries, removed lists dropped projectIds). Un-booted projects are read cheaply from disk — each project's <root>/.ade/ade.db (read-only, no cr-sqlite / no runtime boot) plus .ade/cache/chat-sessions/*.json — so their session status is limited to the last-persisted idle/ended/awaiting; live running/awaiting fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads running). attentionCount counts awaiting/failed chat rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry toolType so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Transcripts are excluded from the roster and load on demand after a row tap activates the owning project; the Hub cover exposes switching/hydration progress and an error with Retry instead of silently ignoring an unhydrated project. Oversized snapshots ride the generic envelope_chunk path. A host without a roster provider (single-project desktop) simply never answers roster_subscribe, so the phone falls back to the active project only | iOS Hub |
| Command routing | Send named actions (chat.send, lanes.create, git.push, prs.getMobileSnapshot, work.listExternalSessions, work.importExternalSession, etc.) | Controller devices |
| Project switching | project_catalog + project_switch_request/result for multi-project runtimes | iOS project hub |
| Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir/forget envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project hub |
| Paired desktop runtime | Full newline-delimited runtime JSON-RPC over rpc_open / rpc_data / rpc_close, plus host-loopback TCP previews over fwd_open / fwd_data / fwd_close. Same-account adoption or a Nearby PIN pairing obtains the required host grant internally; there is no user-facing Share link. Client-claimed device metadata never authorizes either channel | ADE desktop remote machines |
| Runtime status | Runtime broadcasts cluster/version status (brain_status is the legacy envelope name) | All devices |
| Lane presence | Controllers call lanes.presence.announce / lanes.presence.release; the runtime decorates LaneSummary.devicesOpen for 60 s TTL | iOS Lanes tab; desktop runtime presence heartbeat |
Command routing and execution isolation
Controllers never run agent processes. Agent runtimes and CTO chat turns are runtime-exclusive.
Two categories of controller write:
- State-only (create lane metadata row, update a setting): written locally, propagates through cr-sqlite changesets.
- Execution (create worktree, run a terminal command, create a
PR, send a chat message): issued as a
commandenvelope to the runtime, which runs it and replies withcommand_ack+command_result. State changes the command produced flow back through normal changeset sync.
Every command action has a SyncRemoteCommandPolicy:
{
viewerAllowed: boolean;
requiresApproval?: boolean;
localOnly?: boolean;
queueable?: boolean;
}
viewerAllowed: false is the gate for anything that hands a paired viewer a
credential or lets it mint one. sync.getWebPairingInfo and
sync.getDesktopPairingInfo return the raw pairing PIN and a ready-to-use
pairing URL, so a viewer could onboard further devices without the owner;
cto.setLinearToken / cto.clearLinearToken are direct credential-store
writes that accept an arbitrary secret — or wipe the owner's — from whatever
device is on the socket. Paired viewers get the interactive
*LinearMobileOAuth pair instead, whose token Linear mints against a
host-issued session. The registry is the gate here, not the absence of client
wiring.
projectConfig.get and projectConfig.save stay viewer-allowed but redact:
ai.apiKeys holds live provider API keys and the top-level providers bag is
an unvalidated passthrough that historically carried the same, so reads drop
both and writes keep whatever is already on disk. The write side matters as
much as the read side, because every Settings section saves a get → edit → save
round trip of the whole file — a redacted read fed back verbatim would
otherwise erase the host's keys.
Plus a scope (runtime or project) on the descriptor. The
runtime-declared policy and scope are the authority: the iOS app reads
descriptors over the wire and gates UI actions accordingly. Hardcoded
mobile assumptions would be stale after a runtime-side policy change, so
the phone trusts the runtime.
See remote-commands.md for the full action set and the runtime /
project scope split.
External session import commands
Paired controllers can browse and import provider-native CLI sessions through the same runtime command registry that starts Work CLI sessions. The full feature detail lives in External Session Import.
| Command | Policy | Purpose |
|---|---|---|
work.listExternalSessions | viewerAllowed: true | Returns ExternalSessionSummary[] from the runtime's external-session service. Payload mirrors ExternalSessionListArgs (providers, laneId, cwd, scope, limit). |
work.importExternalSession | viewerAllowed: true, queueable: true | Imports one external session into a lane as either target: "cli" (ExternalSessionImportResult.kind = "cli", with sessionId/ptyId and, when available, persisted session) or target: "chat" (kind = "chat", with chatSessionId and required persisted chatSummary). Payload mirrors ExternalSessionImportArgs (provider, sessionId, laneId, target, mode, optional model/permissionMode). |
These commands are viewer-allowed for the same reason as
work.startCliSession: a paired phone or desktop controller is already a
trusted controller for the runtime machine. The controller never reads provider
session files or launches provider CLIs locally; it sends a command envelope,
and the sync authority runtime does discovery, cwd validation, chat transcript
seeding, PTY creation, and provider resume/fork execution on the host.
The feature must be present on the host brain the controller is paired to.
Desktop can point at an isolated lane-built brain with an isolated ADE_HOME,
but mobile normally cannot because there is one sync host on the shared
port/mDNS/tunnel surface. Real mobile E2E therefore requires the paired host to
contain the external-session service and work.* commands, either because the
feature is merged or because a deliberately isolated-port host is running.
Security model
- Device-bound pairing (DPoP): iOS keeps a P-256 key in the Secure
Enclave.
pairing_requestregisters the public key (SyncPairingRecord.dpopPublicKey), and every pairedhellomust then carry a fresh signed challenge (SyncDpopProof: nonce + timestamp, signature overade-dpop-v1\n deviceId\n sha256(pairedSecret)\n ts\n nonce— seesyncDpop.ts). Binding the secret hash scopes proofs to one host; a bounded nonce cache kills same-host replays. Legacy paired devices upgrade on their next connect (TOFU adoption of the offered key); once a key is on record the host fails closed, and bootstrap-token hellos for that deviceId are rejected (no downgrade path). The machine-levelsync-security.jsonstore (requireDpop, env overrideADE_SYNC_REQUIRE_DPOP) additionally rejects paired hellos from devices that never registered a key. The same DPoP evaluation binds on the brain ingress path too (brainProjectActionsSyncHandler— the machine-wide fallback handler that answers before any project host is active), so a paired hello cannot skip proof-of-possession by racing a connection during a host restart. Keys are not restorable from device backups — a restored phone re-pairs with the PIN. - Account bearer transport (plaintext
account): everyhello.auth.kind = "account"is accepted only when the shared listener verified that the socket came from ADE's in-process cloud-relay bridge. The tunnel client attaches a private, per-process 256-bit proof to its loopback WebSocket upgrade; the listener validates the decoded proof with a constant-time comparison and carries the resultingrelay-bridgeprovenance through parked-socket host handoffs. Missing, forged, or stale proof fails closed as a direct connection. The runtime rejects the plaintextaccountbearer on LAN, tailnet, loopback, and every other direct route before verifying the bearer, even when that device already has a pairing record. Existing devices useauth.kind = "paired"with their durable per-device secret and pinned DPoP key on direct routes; PIN pairing remains available on LAN. This guarantees ADE does not send or accept the Clerk account bearer over plaintext direct sync. It does not sender-bind a bearer stolen outside ADE: generic bearer replay through a TLS relay remains possible until the account session/token is sender-constrained to a device key or equivalent platform attestation. - Sealed account adoption (
ade-adopt-v1): the account credential can also reach a machine over a direct LAN/tailnet route — not just the relay — without ever exposing the bearer in plaintext, using a sealed handshake keyed to the host's publishedpubkey. The client sendsaccount_challengewith a nonce and an ephemeral X25519 public key; the host repliesaccount_challenge_okwith its own X25519 ephemeral key and an Ed25519 signature (frommachineIdentitySigningStore) over the canonicalade-adopt-v1 | hostDeviceId | nonce | clientEph | hostEph | ts[ | aead]string. The client verifies that signature against the directory-publishedpubkeybefore releasing any credential, so a machine cannot be impersonated on a LAN. Both sides derive the same AEAD session key via HKDF-SHA256 over the X25519 shared secret and nonce; the client then sends ahellowithauth.kind = "account_sealed"carrying the sealed account attestation, and the host returns the minted paired credentials in a sealedhello_ok. The seal cipher is negotiated: the client advertises the AEADs its crypto backend supports (chacha20-poly1305,aes-256-gcm), the host chooses the first it also supports, echoes it inaccount_challenge_ok, and binds the chosen AEAD into the signed challenge string so it cannot be downgraded on the wire — this is what lets a packaged Electron whose bundled BoringSSL lacks ChaCha20-Poly1305 adopt overaes-256-gcminstead of failing. A client whose advertised set does not overlap the host's is rejected. Legacy clients that omit the AEAD list remain compatible by falling back tochacha20-poly1305, but their chosen AEAD is not yet signature-bound; the host emits a warn-levelsync_host.legacy_adoption_aead_unboundrecord so the supported-client floor can be measured before that path is disabled. The challenge is single-use and TTL-bounded (60 s); it is required before a sealed hello is accepted.ade-adopt-v1protects the exchanged credentials (bearer, DPoP proof, minted secret), not the confidentiality of the subsequent session: after adopting over a plaintextws://route the ongoing sync stream has the same on-path exposure as any other direct paired reconnect. See the client connect-flow narrative in Remote Runtime. - Pairing: direct machine-to-machine Nearby and phone QR/Nearby pairing use
the same user-approved PIN + DPoP flow. The desktop synthesizes its Nearby
pairing input from discovery; it does not expose a Share link or manual
address field. Legacy shared bootstrap-token hellos are rejected over Relay.
New hosted-browser connections instead require account sign-in and adopt a
directory machine through Relay; only browser environments paired before
this release retain their saved local/direct reconnect path. Direct pairing
uses a user-set 6-digit PIN stored as a PBKDF2 hash in
~/.ade/secrets/sync-pin.jsonon the runtime machine. The runtime never auto-rotates or TTLs the PIN; the user manages it from the This computer card in Connections and clears it when they want to stop accepting new pairings. Plaintext is process-local and intentionally unrecoverable after restart, so Connections and CLI surfaces treathasPin() && getPin() == nullas "configured but hidden". They still allow pairing with the existing PIN if the user knows it, and tell the user to generate/set a new PIN only if they need to display or copy one. The PIN unlocks generation of a durable per-device secret that the phone stores in its Keychain; subsequent connections use that paired secret, not the PIN. - Rate limiting: the runtime tracks failed
pairing_requestattempts per remote IP. Five failures put that IP into a 10-minute cooldown during which new pairing requests are rejected without touching the PIN store. - Secrets never sync.
.ade/local.secret.yaml(provider API keys, ADE CLI configs) is per-machine. Linear tokens stay in the active project's machine-local.ade/secrets; GitHub tokens and AI provider tokens stay on the runtime machine. - Transport: WebSocket auth via PIN / paired secret / bootstrap token on every connection. Tailscale WireGuard encryption applies when over tailnet; LAN connections rely on pairing token validation. TLS is not enforced for localhost/LAN; the runtime listens on all interfaces (intended for trusted LAN and tailnets).
- Cloud tunnel relay (account-gated, no user toggle): the brain keeps an
outbound HMAC-authenticated tunnel to the
apps/tunnel-relayCloudflare Worker so a phone off the LAN/tailnet can dial the machine over TLS with zero configuration. Phones rank authenticated direct routes LAN → Tailscale ahead of Relay but race all of them together, so a stale saved LAN/Tailscale endpoint cannot hold Relay off; the whole race is bounded by one 10-second budget. The relay pipes WebSocket bytes after terminating TLS. The normal ADE hello / PIN / paired-secret / DPoP handshake still runs inside that pipe, but it is not end-to-end encrypted: the relay can read paired secrets and runtime/sync payloads. Treat the relay operator as trusted for confidentiality. Adding end-to-end payload encryption to the relay path is planned security work. The host opens and advertises Relay only while its ADE account lease is current and it holds the machine-wide sync host lease. The relay Durable Object keeps one host control socket permachineKey, so relay ownership is a machine-level singleton, not a per-process capability; a runtime without the lease neither dials the relay nor publishes to the directory. Every paired Relay hello — including first-time PIN pairing — must also carry a fresh short-lived Clerk token whose subject matches the account signed in on the host; the proof is never persisted. Direct LAN/Tailscale hellos do not need an account token. Sign-out, account switch, expiry, or a refresh failure after the last known lease has expired closes Relay peers and removes directory access; a transient refresh exception while that lease is current leaves the route intact. The device-bound paired secret remains available for direct LAN/Tailscale reconnect regardless of whether it was minted after PIN pairing or sealed same-account adoption. A verified same-owner account hello with the pinned DPoP key may rotate the paired secret so a lost credential-delivery response can be retried safely, and may adopt a still-local QR/PIN/SSH record for the same device into the account. That adoption is authorized by the DPoP proof against the key already pinned on that record plus a verified same-account attestation; a record with no valid pinned key, and a record owned by a different account, are both refused. It never converts provenance —localTrustOriginkeeps the record out of the sign-out delete sweep, which demotes it back to local trust instead. ThemachineKeyis an unguessable 32-hex identifier and the tunnel upgrades are HMAC-signed with a per-machine secret. Relay availability now follows the host's account session: sign-in starts and advertises it, and sign-out stops it. The old Settings/CLI kill-switch and itsenabled/enabledSetByUserfields are removed; existing files are rewritten without those fields, so machines whose operators had disabled Relay are re-enabled when signed in after this release. The live relay URL is also advertised to already-paired phones inhello_ok/brain_status(cloudRelayWssUrl), so devices paired before the relay existed learn the route without re-scanning a QR. Relay publication is honest: the host advertises arelayendpoint in the account directory only after an end-to-end self-probe (syncRelaySelfProbe) confirms a controller-shaped dial actually round-trips back through its own bridge, and it keeps that route live with an application-level{t:"ping"}/{t:"pong"}control keepalive that catches "zombie" controls the Cloudflare edge still answers at the transport layer after the Durable Object has died (seesyncTunnelClientService.ts). A control that connects but cannot round-trip, or that goes zombie, is torn down and never publishes a relay route. - Secret isolation: each device stores its own pairing secret in its OS keychain.
- One-release trust reset: the first packaged desktop launch carrying the migration removes only old remote-target/pairing/runtime-host grant files and confirms a background-service restart before committing its marker. iOS removes connection tokens plus machine-scoped profiles/cursors/queued state only after Keychain clearing succeeds. Hosted web removes old IndexedDB environments and selection once. Account sessions, stable machine/device and DPoP identities, pairing PINs, projects, SSH files, analytics choices, and unrelated browser state are preserved. New pairings created after each marker follow the normal local/account ownership lifetime.
- Execution isolation: the ADE runtime runs agents; controllers do not.
- External local files stay desktop-local. Files opened in the desktop
from Finder / OS open-file events or local drag-and-drop are registered as
externalworkspaces on that desktop process. The sync host filters those workspaces out of mobilelistWorkspacesresponses and rejects mobile file requests that target them, so pairing a phone does not expose arbitrary local folders.
Current implementation status
| Component | Status |
|---|---|
Sync service owned by ade serve runtime | Implemented |
| Desktop in-process sync host | Disabled by default (ADE_ENABLE_DESKTOP_SYNC_HOST=1 for diagnostics) |
Multi-project runtime + project_switch handshake | Implemented |
| Hosted web workspace Hub + four-client LRU machine pool | Implemented |
| Hosted web repository tabs with per-binding machine selection | Implemented |
SyncRemoteCommandDescriptor.scope (runtime / project) gating | Implemented |
| cr-sqlite extension loading (desktop/runtime) | Implemented |
| Pure-SQL CRR emulation (iOS) | Implemented |
| CRR marking for eligible tables | Implemented (dynamic startup) |
| Changeset extraction/application | Implemented |
| WebSocket sync server | Implemented |
| Sync protocol (JSON + negotiated deflate with legacy gzip fallback) | Implemented |
| File access sub-protocol | Implemented |
| Terminal stream sub-protocol | Implemented |
| Chat stream sub-protocol | Implemented |
All-projects chat roster sub-protocol (roster_subscribe/snapshot/delta, mobile Hub) | Implemented |
| Device registry table | Implemented |
| Desktop peer client + account/Nearby/SSH connection paths | Implemented |
| Sync authority transfer | Implemented |
| Shared ADE scaffold portability for desktop clones | Implemented |
| PIN-based phone pairing + per-device secrets | Implemented |
| Live chat-event push from runtime | Implemented |
| Mobile project catalog + project switch handoff | Implemented |
| Mobile project actions (browse/open/create/clone/list GitHub repos/remove from list) | Implemented |
| Brain-level shared listener (peers adopted across project switches) | Implemented |
Bidirectional chunked envelopes (envelope_chunk, 720 KiB frame budget, 30 s bounded reassembly) | Implemented |
Typed sync protocol version floor/mismatch (hello_error, close 4406) | Implemented |
Per-host-DB sync cursors (serverDbSiteId / remoteDbVersionBySite) | Implemented |
Resumable chat streams (per-session seq + sinceSeq replay buffer) | Implemented |
| Mobile changeset diet (heavy never-read tables filtered for phones) | Implemented |
Lane presence decoration (devicesOpen) | Implemented |
PR mobile snapshot (prs.getMobileSnapshot) | Implemented |
| iOS local replicated DB | Implemented |
| iOS Lanes / Files / Work / PRs / Settings tabs | Implemented |
| QR pairing UX | Implemented (payload v3 smart URL + iOS camera scanner; PIN entered separately) |
| Device-bound pairing (DPoP, Secure Enclave P-256) | Implemented (host + brain ingress; requireDpop / ADE_SYNC_REQUIRE_DPOP) |
Cloud tunnel relay (off-LAN transport, relay candidate) | Implemented whenever the host is signed in, with no separate toggle and with same-account per-connection proof (syncTunnelClientService + apps/tunnel-relay) |
| Relay end-to-end self-probe + zombie-control detection (honest relay publication) | Implemented (syncRelaySelfProbe, JSON control keepalive, sync.runSelfProbe, ade doctor relay check) |
| Relay tunnel + account-directory publisher gated on the machine sync-host lease | Implemented (syncHostSingleton authority registry, relayTunnelAuthorityGate, runServe publisher gate) |
| Account publication + relay for a machine with no registered project | Implemented (projectlessSyncSnapshot, machineRelayTunnel, runServe publisher snapshot fallback) |
| One hello parser + one account-hello gate chain across both ingresses | Implemented (syncHelloProtocol, syncAccountHelloAuth) |
| Machine-level pairing PIN / device forget / DPoP posture on a projectless brain | Implemented (brainMachineSyncStores, ProjectlessSyncControls, withSyncService) |
Code-first hello_error classification with non-destructive account/session/host-state codes | Implemented (account_not_signed_in, account_verification_failed, host_update_required, and account_session_changed across desktop, web, and iOS) |
Relay eviction (4505) suppression + surfaced outage | Implemented (bounded re-attempts, 10-minute re-arm, routeHealth.relay.relayControlSuppressed*, ade doctor relay row, desktop relay-offline banner) |
Sealed account adoption over direct routes (ade-adopt-v1, host pubkey identity, LAN → tailnet → Relay fallback, negotiated ChaCha20-Poly1305 / AES-256-GCM AEAD) | Implemented (machineIdentitySigningStore + adoptChannelCrypto; desktop + iOS clients) |
Legacy manual-pairing adoption into an account (DPoP-gated) + localTrustOrigin demotion on sign-out | Implemented (syncPairingStore.pairPeerViaAccount / revokeAccountOwnedExcept, syncHostService account hello) |
| Push notifications + Live Activities (APNs relay) | Implemented (see push-notifications.md; on-device E2E needs a physical iPhone) |
| Tailscale integration | Implemented (address candidate + mDNS TXT + per-node tailscale serve publication on the live sync port) |
| Clean, published lane + Work chat handoff between connected desktops | Implemented (contract) |
Gotchas
-
Phone-sync port 8787 is canonical. Bind order always tries 8787 first, even when device-registry
lastPortis 8788. Zombie reap may terminate a same-channel wedged predecessor still holding 8787 (excluding only this process pid). A live listener that landed on a fallback keeps probingtryMigrateToPort(8787)so phones that saved 8787 reconnect without a restart. The "ADE is already running with phone sync" dialog is the cross-channel case (ADE vs ADE Beta) and is intentional. -
Cross-machine session handoff is not database sync or provider-session migration. It publishes the exact Git commit and sends a bounded, sanitized capsule to a compatible destination runtime. Provider-native thread ids, full transcripts, terminals, artifacts, caches, secrets, and dirty worktree data remain on the source. See the handoff contract.
-
The release trust reset is deliberate and non-recurring. Do not broaden it into a general cache/account wipe or rerun it on dev launches. Desktop's pending marker exists so a failed service restart is retried without deleting newly created pairings again; iOS writes its marker only after connection tokens clear; web scopes its version marker to the environment store.
-
A table only leaves
MOBILE_CHANGESET_EXCLUDED_TABLESterritory if the phone has another way to get it. The diet is not "drop what looks big" — it is "drop what the phone re-fetches anyway."pull_request_snapshotsis the clearest case: 11.2 MB of a 28.1 MB synced project DB (39.7%), and iOS reads it in exactly one query (the per-PR detail behindfetchPullRequestSnapshot(prId:)) which it populates on demand throughprs.refresh→replacePullRequestHydration. That works for every paired build, however old, becauseprs.refreshandprs.getMobileSnapshotare both in the required remote-command set. Lists and badges are unaffected — the slimpull_requestsrows still replicate. Devices paired before an exclusion keep the rows they already have (nothing deletes them); they simply stop receiving updates through the changeset pump. Excluding a table the phone reads with no on-demand path would silently blank a surface, so check the iOS queries and the required-command set before adding one. The seven event-log tables added alongside the PR cache clear that bar the other way: they have no iOS read path at all (the only reference anywhere underapps/ios/is theDatabaseBootstrap.sqlcreate table), so nothing on the phone can render them and there is nothing to re-fetch. -
The mobile diet is an outbound filter; a local-only conversion is not. They look similar and their failure modes are opposite. Excluding a table from
MOBILE_CHANGESET_EXCLUDED_TABLESonly drops rows on the way out to a phone — CRR metadata is untouched, existing rows on paired devices are left alone, and the peer's ack watermark still advances through the filtered versions, so there is no apply hazard for a peer on any build. Moving a table intoLOCAL_ONLY_CRR_EXCLUDED_TABLESchanges what cr-sqlite knows about the table and does have an apply hazard, which is whyapplyChangesskips inbound rows for those tables (see the CRDT model). Do not reason about one from the other. Related but independent: whether the host prunes a table is a third, orthogonal question —linear_ingress_eventsandworker_agent_runsare on the mobile-exclusion list yet deliberately never age-pruned on the host. -
A host-side check does not guard a replicated column. If the host decides a value, a controller writing that same column into its own replica can win the merge and undo the decision — the check was never reached. The fix is to stop the controller writing it (a local, non-persisted overlay is what buys the optimistic feel) and to drop the column from that peer's inbound changesets, not to add another host-side check. Both halves are needed: the client change fixes new builds, the host filter covers every paired device still on an old one. See Host-authoritative columns.
-
The wire and the stored transcript share one chat-event compaction policy, and the wire runs storage compaction first.
compactChatEventEnvelopeForSyncis an adapter; the policy isshared/chatEventCompaction.ts. Two implementations with two cap tables is what this replaced, and they drifted: the wire only redacted inline images, so a multi-megabyte event went out live and came back small after reconnect hydration. Compaction is applied to events that are already compacted (hydration, the replay ring), so it must be idempotent. Dropping a field from the wire (tool_result.structured,toolResultMeta) is safe without a capability gate only because no client decodes it; anything that adds or reshapes a wire field still needs one. -
The runtime owns sync. Desktop is a client. A desktop window bound to a remote runtime is not the sync authority for that project; the remote runtime is. Code that wants the sync service must reach into the runtime IPC bridge, not into the renderer or the Electron main process.
-
Relay ownership is machine-wide, so "has a listener" is never the test. Any runtime can bind an ephemeral sync listener — a dev
ade serve, a headless one-shot, an embedded fallback. Only one may dial the relay or publish to the account directory, because the relay Durable Object keeps one host control socket permachineKeyand evicts the previous holder with close code4505. New machine-exclusive subsystems must gate onholdsSyncHostSingleton()(throughrelayTunnelAuthorityGateor the same authority subscription), and must tolerate the momentaryfalsethat a project switch produces by riding it out forSYNC_HOST_AUTHORITY_RELEASE_GRACE_MSrather than reacting on the edge. -
A project is not a precondition for anything machine-level. Hosting phone sync, publishing to the account directory, and dialing the relay all gate on the sync-host lease plus a bound shared listener — never on an open or registered project. Copy that tells a user to open a project in order to link or publish a machine is wrong, and per-state advice belongs in
describeUnpublishedAccountDirectoryrather than in each surface. -
ADE_ENABLE_DESKTOP_SYNC_HOSTis a diagnostics escape hatch. If you turn it on, both an in-process host and the standing runtime can be alive simultaneously on the same machine — that's intentional for comparing behaviors, but production builds should never run with that flag set. -
Project-scoped commands need
projectId. A runtime hosting multiple projects has no implicit "current project". Forward the activeprojectIdon every project-scoped command or the runtime rejects withcode: missing_project. The host accepts the runtime catalog id and the DB-local project id as aliases for the same open project when both are known. -
CRR retrofit strips non-PK UNIQUE constraints. Upserts on synced tables must target the primary key only. Use explicit select-then-update for non-PK merge cases.
-
Bootstrap token must match on every connection. A changed token invalidates all existing connections until paired devices are re-provisioned.
-
The runtime listens on all interfaces. Treat the current posture as trusted-LAN/tailnet only; TLS is not enforced for localhost/LAN. Revocation works per paired device from the appropriate Connections tab.
-
The pairing PIN is user-managed, not ADE-managed. There is no expiry and no rotation. A machine that leaves the PIN set is perpetually pairable by anyone on the network who knows the digits (subject to the per-IP rate limiter). Clearing the PIN from the This computer card in Connections is how you stop accepting new direct pairings; already-paired devices keep their per-device secret and remain connected. Because only the hash persists, a restarted runtime can report that a PIN is configured but cannot reveal it.
-
brain_*is legacy naming. In new docs and code comments prefer "sync authority" or "machine runtime"; existing database column names are kept for compatibility. -
iOS and desktop do not share the cr-sqlite binary. iOS uses a pure-SQL emulation because Apple platforms reject
sqlite3_load_extension()andsqlite3_auto_extension(). Changeset wire format is identical; cr-sqlite feature parity is not guaranteed — any desktop-only cr-sqlite feature that ADE grows to depend on must also be implementable in SQL triggers on iOS. -
iOS sends unpacked primary keys; the desktop/runtime path repacks them. The iOS emulation captures
crsql_changes.pkas the raw scalar (a string, integer, or already-bytes value) instead of the cr-sqlite packed type-tagged byte string desktop emits. On the receive side,apps/desktop/src/main/services/state/kvDb.tsappliesnormalizeIncomingCrsqlChangeto every inbound row before thecrsql_changesinsert: bytes that already look packed are passed through, while raw strings / ints /0/1are wrapped into the matchingpackedCrsqlPrimaryKeybyte layout the native cr-sqlite extension expects. Skipping this step is how phone-side edits silently fail to apply on the desktop. -
Rolling schema removals are filtered before apply. Peers on older builds may still export changes for dropped local tables such as
unified_memoriesand its FTS side tables.kvDb.tsfilters those rows, plus rows for tables that no longer exist locally, before opening the apply transaction. A batch that contains only ignored tables is a no-op and preserves the local database version. -
Controller command queues replay on reconnect, but an attempted live chat send is ambiguity-sensitive. If the runtime advertises
chat.sendas queueable and the user submits while already offline, iOS stores the command locally and replays it with the samecommandId. After a livechat.sendwas attempted, however, a timeout or transport loss does not prove that the host failed to start the turn. iOS therefore does not queue or resend that message: it restores the draft and asks the user to check the transcript before a manual retry. Do not assume synchronous semantics from the phone side.