---
title: Cross-Device Sync
---

# Cross-Device Sync

> **Status:** available in Settings, **OFF by default (opt-in)**. The
> `cross-device-sync` toggle (Settings → Cross-Device Sync) is the real off-switch —
> sync runs **only when you turn it on**, and the change takes effect live (see
> "Turning it on and off" below). The engine, the live background runner, the
> new-machine experience + device management, the storage housekeeping (oplog
> compaction + garbage collection), and the diagnostics readout are all built. One
> hosted step remains an owner action — a functions-only relay deploy that lights up
> the GC delete/list ops; core push/pull sync uses the live `/t/sync` relay and GC
> simply no-ops until then (see "Storage housekeeping").

## What it is

Sign into Omniscio on any of your computers and your setup is already there — sessions,
projects, recipes, automations, bookmarks, non-secret settings — kept in sync
**end-to-end encrypted**. The cloud is an **encrypted courier**, not a new
authority: your local `mission-control.db` stays the source of truth, and Omniscio's
server holds only ciphertext it **cannot read or undetectably tamper with**
(zero-knowledge). It evolves the folder-based [Backup Mirror](backup-mirror.md)
into a seamless, Omniscio-hosted experience — no cloud drive to own or configure.

**Secrets never sync** (API keys, OAuth tokens): they're per-machine and
re-approved once via the existing [Post-Restore Credential Wizard](post-restore-credential-wizard.md).

## Where to find it

Cross-Device Sync has its own section in **Settings**, reached from the app's Settings gear — that section holds the on/off toggle, your device list, the key-rotation control, and the card that offers to switch you over from the folder-based backup if you were using it. A read-only summary of what sync has been doing appears in **Settings → Diagnostics**. When another computer asks to join, you are not expected to go looking for any of this: the request arrives as a card in your inbox.

## How it behaves

### Scheduled jobs run on one computer (not twice)

Your **scheduled jobs** — crons and scheduled recipes — sync like the rest of your setup, but they
**run on only one of your computers**, not on each one at the same time. Without this a synced cron
would fire on every signed-in computer at once, doing the work (and spending) twice.

- **A cron runs on the computer that owns it.** A cron you create binds to the computer you made it
  on; move it by editing it on the computer you want it to run on.
- **Editing an existing cron binds it.** Crons created before you turned sync on keep behaving as
  before until you next edit one — that edit binds it to the current computer. (Scheduled recipes
  bind automatically the next time they run.)
- **Omniscio's own background jobs still run on every computer** — routine maintenance like folder
  cleanup and drift checks stays per-computer and is never affected.
- **Remove a computer and its jobs move on.** De-enroll a computer that owned some jobs and they're
  released for another computer to pick up — they never silently stop.

This is fully effective once **both** computers are on the updated app. See
`cross-device-sync-contract.md`
(`device-scoped-execution`).

### Sessions from another computer come over Paused (and resumable)

A session that was **running** on one computer arrives on your others as **Paused**, not running — its
live Claude process only exists on the machine that started it, so nothing auto-runs (and nothing
double-charges) when your setup lands elsewhere. Open one and it **picks up right where it left off**,
with its full history. A session you're genuinely running on the receiving computer is never touched.

See `cross-device-sync-contract.md`
(`session-status-device-local`).

### Turning it on and off

Cross-Device Sync is **OFF by default** — it's opt-in. The **Enable Cross-Device
Sync** toggle in its Settings section is the real switch:

- **Off by default.** With the toggle off, the background sync runner never starts,
  installs no capture triggers, and does nothing — for every user, even signed in.
  Nothing leaves your machine until you opt in.
- **Takes effect live.** Flipping it on starts sync and off stops it **immediately —
  no restart** — from any surface (the desktop UI, the CLI, or the mobile app);
  turning it off tears down the runner and its capture triggers at once.
- **The toggle reflects the real state.** It reads on only when you've actually
  enabled it — it won't snap back to on when it's off.
- **It never slows the app.** The background loop paces itself, leaving a minimum
  rest between sync cycles, so even on a busy machine with many sessions it can't run
  back-to-back and compete with what you're doing (the app's "never slow the user"
  rule) — and the pacing can't starve it, so your devices still stay in sync.

Under the hood the runner gates on three things — the feature is visible, you've
enabled it, and you're signed in — and a settings change reconciles it on the spot.
See `cross-device-sync-contract.md`
(`runner-inert-for-real-users`).

### Coming from folder Backup Mirror?

If you already use the folder-based [Backup Mirror](backup-mirror.md), Cross-Device Sync
offers a **one-time, additive** way to switch. Turn the feature on and you'll see a "Switch
from Backup Mirror to Cross-Device Sync" card in the sync settings (plus a matching nudge in
**Settings → Backup & Restore**). Adopting is **non-destructive** — your Backup Mirror keeps
working, nothing is deleted, and setting up sync just enrolls this device (the same flow
below), so your existing setup rides the encrypted courier from now on. Once you're synced,
an optional hint lets you turn folder Backup Mirror off if you no longer want a second
backup — reversible anytime. The offer appears only while the feature is on (it's off by
default), and it's shown at most once.

### The key model (why there's a recovery code, not a password)

One random **data key (DK)** encrypts everything. Omniscio uses federated sign-in
(Google), so there's no password to derive a key from without the server seeing
it — instead the unlock is **device enrollment** (the Signal / 1Password linking
pattern):

- The **first device** generates the DK, seals it under a machine-local key in the
  OS keychain, and uploads two _wrapped_ copies to the locker: a **recovery-code**
  wrap and a **device** wrap. It shows the recovery code **once**.
- **Every later device** must **enroll** to get the DK — two ways (below).
- The server only ever stores _wrapped_ copies of the DK; day-to-day unlock is
  silent (the device-local key is already present).

The **recovery code** is high-entropy (≥128-bit, Crockford base32) so the
recovery-wrapped DK can't be brute-forced offline. It is shown **once** — the app makes
you tick "I've saved my recovery code" before you can continue, and there is no way to
re-fetch it — so store it somewhere durable (a password manager). It is your **only**
break-glass: lose every enrolled device **and** the recovery code → the data is
permanently unrecoverable (the price of true zero-knowledge; the server holds only wrapped
copies it cannot open). A key **rotation** mints a _new_ code and retires the old one, so
save the new one each time you rotate.

### The two everyday flows

**Steady state** (a device you already use): a local change → debounce → append an
encrypted oplog segment → atomically advance the manifest. Quiet and continuous, and it
self-heals through a key rotation (`syncCycleWithRotationRecovery`). A throttled
**maintenance pass** rides the same cycle to keep storage bounded (see "Storage
housekeeping" below).

**New machine** (Phase 3): install Omniscio → sign in → **enroll this device** → the app
downloads + decrypts + replays the oplog to **rebuild** the local DB → the
credential wizard walks you through re-approving accounts → live. The modal offers an
optional **name for this device** first (see "Device management" below — you can also
set it later); the name is applied once enrollment succeeds, so a failure there never
blocks the rebuild. Two enroll paths, offered as tabs in the enrollment modal:

- **Approve on another device** — this device publishes a pending request (its
  public key) to your device registry; a device you already use re-wraps the DK to
  that key (ECIES over X25519) and drops it in the locker; this device polls,
  unwraps with its machine-bound private key, and finishes. The approval screen
  shows a short **key fingerprint** so you can eyeball-match it.
- **Enter recovery code** — paste the one-time code; it unwraps the
  recovery-wrapped DK directly.

**You get told, you don't have to go looking.** When a new device asks to join by
the "Approve on another device" path, one of your **already-set-up computers**
notices within about a minute and pops up a **"A new device wants to join your
sync"** card in the inbox (plus a native notification if the app isn't focused).
One click on it takes you straight to the approval screen — no hunting through
Settings. It's still a real approval, not a blind one-tap: you confirm the new
device's short **code** there before approving (approving lets it decrypt your
synced data). The card clears itself once the device is approved, denied, or gone,
and — like everything here — it only appears while sync is turned on. Approving
stays a **human-at-the-computer** action: an AI agent or your phone can't approve a
device for you. If several devices ask at once, they collapse into one card; a
long-abandoned request is ignored.

**And the new machine gets told too — it can't be left quietly un-joined.** The prompt
above is the *approving* side; the waiting side has its own. While a computer is signed in
to an account that already syncs but has not joined it yet, that computer shows its own
**"This computer has not joined your sync yet"** card (plus a native notification if the
app isn't focused), and it clears itself the moment you join. That matters because the
enrollment window that opens by itself lives in one app window's memory: dismiss it, never
open Settings, or have the very first check fail, and nothing else used to say a word. The
check itself no longer gives up either — it retries on a widening backoff, tells you if it
runs out of tries (with a **Try again** button rather than silence), and re-runs the moment
you finish signing in, so a computer switched on mid-sign-in lands in the join window
instead of waiting for an app restart. Neither the card nor the window joins or approves
anything on its own: they only take you there, and the fingerprint check on your other
computer is untouched. See `enrollment-never-silent` in the contract.

Restore is **additive-merge, never a wipe** — rows that exist only locally are
kept (remote deletes still apply), so adopting sync on a machine that already has
data merges rather than destroys. When a remote delete _does_ apply — a session or
project you removed on another computer — its dependents are cleared with it (its
messages, plus device-local extras like terminal history or tracker rows), so the
deletion lands cleanly on every computer instead of leaving orphaned leftovers
behind.

**Same folder or same name on both computers?** If each computer independently created a
**project for the same folder** — or a **tag with the same name** — sync treats them as one and
**merges** them instead of stalling: your computer keeps its own copy and the other computer's
chats (and links) are filed under it, so you end up with a single project/tag carrying all its
history, never a duplicate. See
`cross-device-sync-contract.md`
(`unique-collision-converges`).

**Attachments follow the same rule** — a file deleted on one device is removed on
your other devices on the next pull, and SAFELY: each device remembers the set it
last synced, so it never deletes a file still referenced by the current set, one
you made locally and haven't uploaded yet, or one you've since edited (your edit
wins and re-syncs). It's behind the same default-off gate; a delete on the origin
device is no longer resurrected by its own next pull.

### Computers on different app versions? Sync pauses cleanly

Your setup evolves between releases — a new feature can add a new field to your projects,
sessions, and the rest. If one computer is a version or two behind and a newer one sends it
data shaped for a field the older one doesn't have yet, sync **pauses on the older computer
with a clear "update Omniscio on this device to resume syncing" message** — it never crashes,
and nothing is half-applied (the whole sync attempt rolls back). Update the older computer and
sync resumes on its own; the pause clears itself. A newer computer receiving older data is
unaffected — a field the older computer didn't send just takes its normal default. See
`cross-device-sync-contract.md`
(`replay-schema-drift-pauses-cleanly`).

### Storage housekeeping — compaction + GC (keeps cost bounded)

Left alone, the oplog would grow forever (every change is an append), so a **maintenance
pass** — throttled (≥1h) and riding the normal sync cycle so it never blocks a sync — keeps
both the cloud footprint and a new-machine restore bounded to _current_ state, not lifetime
history:

- **Compaction** folds the accumulated oplog objects into a **base snapshot** (a
  delete-preserving coalesce — last write wins per row, so deletions survive) and resets the
  live oplog. A new machine applies the snapshot first, then the recent tail. A `readerFormat`
  gate makes an older app **refuse** a compacted manifest rather than silently drop the folded
  history, and the snapshot sits inside the manifest MAC.
- **Garbage collection** reclaims the now-orphaned objects, but only after they've been
  unreferenced across **two** maintenance passes (≥1h apart) — so an object mid-upload on
  another device is never collected. It never touches an object the current manifest
  (including the base snapshot) still references, and it holds even against a lying object list.
- **Account deletion** purges your whole locker server-side (best-effort, retried by a daily
  reconciliation sweep) — a deleted account's devices may never run again to clean up after
  themselves.

The relay's **delete** + **list** operations back GC; until they're deployed the client stays
optimistic and GC simply no-ops (the feature is off by default regardless).

### Diagnostics readout (observability)

Because the runner works quietly in the background, a read-only **diagnostics** readout in
**Settings → Diagnostics** ("Cross-device sync diagnostics") shows what it actually did — visible
only while the feature flag is on, so a real user never sees it:

- **Current status** — synced / syncing / paused (offline · metered · daily-limit) / needs sign-in
  or enrollment / failed, plus the last-synced time and the last humanized error.
- **Running totals** — cycles run, failures, bytes uploaded/downloaded, objects compacted, orphans
  reclaimed (since the app started or the last account switch).
- **Recent cycles** — the last several sync cycles: objects applied, conflicts, **bytes moved** up
  and down, the manifest version, and any failure reason.
- **Recent maintenance** — the last several housekeeping passes: whether compaction folded (and how
  many objects → snapshot bytes), orphans reclaimed, and orphans still pending their second strike.
- **Locker size / health** — total objects in your cloud locker, how many the current manifest
  references (the live set), and how many are orphaned garbage, refreshed on each maintenance pass.

Everything is **zero-knowledge-safe** — it carries only counts, byte totals, versions, timestamps,
and already-humanized errors, never a secret, key byte, row value, or object name. The same numbers
are also written to the app log as structured `[sync-metrics]` lines for after-the-fact debugging.
The whole readout stays behind the default-off gate: with sync disabled nothing is recorded and the
readout is empty, and it resets when you switch accounts.

### Device management (Phase 3, in the Settings section)

- **Your devices** — a per-user list (each device is public metadata only:
  label, key fingerprint, last-seen; **no key material**). It lives in Firestore
  `user_sync_devices/{uid}/devices/{deviceId}`, readable/writable only by you.
- **Naming a device** — setting up sync asks you to name the computer before it
  provisions, and the enrollment modal offers an optional name for a machine
  joining later. **Rename** (the pencil on any row) changes it afterwards, from any
  of your machines — including the one you are sitting at, whose row otherwise has
  no actions. Without a name a device falls back to the neutral **"This device"**,
  which is unambiguous on one machine and useless on two, so every row you have not
  named reads identically. The app will **never** name a device for you from the OS
  hostname: the label is the one sync field stored in the cloud as **readable
  plaintext**, and consumer hostnames embed the owner's real name — so the dialog
  says so and lets you choose. The rename writes `label` alone (it cannot disturb
  the public key device-approval depends on) and carries an
  exists-precondition, so renaming a device that was removed on another machine
  fails cleanly instead of resurrecting a ghost record.
- **De-enroll** — removes a device from your registry.
- **Rotate keys** — the security response to a lost/compromised device:
  re-encrypts the **whole locker** under a brand-new DK and **revokes** any removed
  device (it is simply not re-wrapped, so it can read nothing new), while your
  remaining devices adopt the new key automatically on their next sync. Yields a
  **new** one-time recovery code. Removing a device then rotating is the full
  revoke. (A device doing its very first restore at the exact instant a rotation
  runs retries until it completes — a documented residual window.)

### Security guarantees (never-regress)

- **Zero-knowledge** — the server only ever holds ciphertext + wrapped DK copies;
  no plaintext row, DK byte, or `enc:` secret ever leaves a machine.
- **Cross-tenant isolation** — you can reach ONLY your own locker (the relay
  derives the storage prefix from your verified token, never the request body) and
  ONLY your own device registry (`request.auth.uid == uid`).
- The sync IPC actions are classified **human-only** (`security-boundary-human-only`
  in the CLI-parity manifest): an AI agent can never enroll a device, approve one,
  or rotate your keys via the CLI — each needs your physical device and/or recovery
  code.

## For agents

### Where the code lives

Engine + crypto + formats + the Phase-3 additions (device registry, enrollment,
restore, rotation, IPC, the gated Settings UI + enroll modal, the never-silent join prompt —
`SyncEnrollmentGate` on the renderer and `sync-enrollment-notifier.ts` in main, both answering
from the one `resolveEnrollmentState` the status IPC uses) + the pre-ship
housekeeping (oplog compaction, the two-strikes GC + throttled maintenance pass, the
relay delete/list ops, the account-deletion purge) are catalogued with
their invariants and named tests in the contract:
`cross-device-sync-contract.md`.
Architecture north-star: [the design spec](../architecture/2026-07-16-seamless-cross-device-sync-design.md).

## Related

The folder-based [Backup Mirror](backup-mirror.md) is the older way to get the same outcome, and the page explains when to keep it alongside sync. Secrets deliberately do not travel, so a restored machine re-approves them through the [Post-Restore Credential Wizard](post-restore-credential-wizard.md). What sync does to your account when you leave is on the [account deletion and reset](account-deletion-and-reset.md) page, and the setup that sync replaces as the everyday way to move data between machines is [Data Transfer](data-transfer.md).
