Files
2026-07-13 13:36:53 +08:00

8.2 KiB

Cloud agent sync

Syncs desktop profiles (the app's agents) with the signed-in hermes-account-login's cloud agents, bidirectionally, via the backend's /api/agents CRUD.

Phase 1 covers the free parts from the backend's docs/agent-sync.md: color, persona (SOUL.mdsystemPrompt), memory (memories/MEMORY.mdmemory), and config basics (model/provider only — never the whole config.yaml, so no secrets leave the device). Skills, automations, and sessions are deferred. Deletions never propagate in either direction.

Sync engine

src/main/agent-sync.ts#syncAgents runs one single-flight pass: link local profiles to cloud agents, reconcile each part, create missing counterparts on both sides, and unlink mappings whose cloud agent disappeared.

The stored link (a profile's cloud agentId) is also read by wallet-token-balances#Wallet Sync via src/main/agent-sync.ts#getLinkedAgentId, so backend-provisioned wallets can be fetched for the same agent.

Requests are bearer-authenticated with the device-login token — the account is located app-wide by src/main/account-store.ts#findAccountProfile (the token is saved under whichever profile was active at sign-in). Linking keys on the cloud agent's stable id; names only match never-synced profiles to their cloud namesakes and are never used to rename.

Links are account-scoped: every state write records the owning backend user id, and a pass skips (never unlinks, never pushes) profiles whose link belongs to a different account — signing out and back in as someone else must not re-upload the first account's agents to the second. A missing cloud agent only unlinks when the state provably belongs to the current account; legacy states without an owner are adopted when their agent exists in the account's list and skipped with a warning otherwise. Wallet flows apply the same rule through src/main/agent-sync.ts#getLinkedAgentAccountId — see wallet-token-balances#Wallet Sync.

Per part, the pure src/main/agent-sync.ts#decidePartAction compares the last-sync base hash with both sides' current hashes: only one side moved → that side wins; both moved (or first sync) → last-writer-wins by timestamp (local file mtime vs the agent's updatedAt). Equal content is always a no-op.

Pushes are built by src/main/agent-sync.ts#buildPushBody, which enforces the backend's field limits by skipping oversize parts with a warning — truncating and later pulling back would destroy local content. An unset local model is also skipped so a PATCH can't clobber the cloud value with an empty string. Pulls write through the existing per-part helpers: src/main/soul.ts#writeSoul, src/main/memory.ts#writeMemoryRaw, src/main/profile-meta.ts#setProfileColor, and src/main/config.ts#setModelConfig (preserving the local base URL).

A profile's stable id (its directory slug), not its editable display name, keys every on-disk operation — getModelConfig/readSoul/readMemoryRaw, the cloud-sync.json state file, and all pull writes — so a renamed profile keeps syncing against the same directory. The display name is used only as the cloud agent's human label (create/name-match/warnings).

Cloud-only agents are materialized locally by src/main/profiles.ts#createProfile, which derives a valid, collision-free id from the cloud agent's display name and returns it; the pulled parts are then written under that id.

State file

Each linked profile stores its mapping in cloud-sync.json under the profile home: the cloud agentId, the owning account's user id, the cloud-side name, and a per-part content hash from the last successful sync (the conflict-detection base).

Parts that failed to push (or were skipped as oversize) keep their old base, so they stay pending rather than being silently marked clean. Deleting the file unlinks the profile — which is exactly what a pass does when the cloud agent was deleted in the console, leaving the local profile untouched.

IPC and UI

The main process exposes agent-sync-run, agent-sync-status, and agent-sync-linked-id in src/main/ipc/register.ts#registerIpcHandlers, next to the account handlers; a completed run also emits agent-sync-updated so the renderer can refresh.

agent-sync-linked-id returns the cloud agent id a profile is linked to (or null) — for the per-profile Sync tab below.

The preload bridge surfaces these as syncAgents, getAgentSyncStatus, getLinkedAgentId, and onAgentSyncUpdated on window.hermesAPI, typed by the shared shapes in src/shared/agent-sync.ts.

src/renderer/src/screens/Agents/Agents.tsx (local mode only — Layout shows a remote notice otherwise) renders the sync affordance in the header: a signed-out hint pointing at the Providers account card, or a Sync button with the last pass's summary (warnings in the tooltip). It auto-runs one pass per visit when signed in, and reloads the profile list when a pass pull-created profiles. src/renderer/src/components/HermesAccountModal.tsx kicks off the first sync right after a successful sign-in.

The profile modal's Sync tab, src/renderer/src/components/profile/ProfileSyncPane.tsx, is a per-profile manual path for when the auto-sync hasn't run: it shows the signed-in account, whether this profile is linked to a cloud agent (getLinkedAgentId), and this profile's outcome from the last pass, with a Sync now button that runs the same app-wide syncAgents(). Sign-in/unauthorized/error states are surfaced inline.

Tests

src/main/agent-sync.test.ts fakes the profile/config/fs surface and stubs fetch, so the tests exercise the reconciliation logic itself; one account-lookup test lives in src/main/account-store.test.ts.

Locates the account app-wide

findAccountProfile finds a session saved under a named profile and prefers the default home when both have one, so sync works no matter which profile was active at sign-in.

Part decision matrix

decidePartAction across the base/local/remote hash matrix: equal content is a no-op, a single moved side pushes or pulls, and both-moved / never-synced parts fall back to last-writer-wins by timestamp.

Push bodies stay within limits

buildPushBody maps parts to exactly the backend's fields and nothing else, and skips oversize persona/memory and unset models instead of truncating or sending empty strings.

Keys on-disk work off the stable id

A renamed profile (id hello-agent, display name Hello Agent) drives all on-disk sync — state file, part reads — off the id, while the cloud agent it creates carries the display name as its label.

Backs up new local profiles

A never-synced local profile is POSTed to the backend with its four parts and the returned agent id is persisted to cloud-sync.json.

An unmapped profile links to its cloud namesake without creating a duplicate, and on first sync the newer side (the cloud, when local files are older) wins part by part.

Pull-creates cloud-only agents

A cloud agent with no local counterpart becomes a local profile (via createProfile, which derives the id from the agent's display name), and its persona/color/memory/config are written locally under that id.

When a mapped cloud agent disappears and the link's recorded owner is the current account, the pass removes cloud-sync.json and keeps the local profile — no DELETE is ever sent in either direction.

Skips foreign-linked profiles

A profile whose state names a different accountId is left completely untouched — state file intact, nothing pushed or pulled — and wallet sync refuses it client-side with status: "foreign" before any backend call.

A legacy state (no accountId) whose agent isn't in the current account's list is skipped with a warning, not unlinked.

It could be a console deletion or another account's agent — and a wrong unlink would re-upload someone else's agent on the next pass, so skipping is the safe read.

Records the owning account

Every successful pass stamps the current account's user id into the state file, adopting legacy links whose agent exists in this account's list.