Skip to content

How sync works

The algorithm

For each file path in scope, sync compares three content hashes (sha256):

  • home — hash of the file in ~/.claude/
  • repo — hash of the file in profiles/<active>/
  • lastSync — hash recorded in the per-machine baseline (~/.agentconf/state/<profile>/meta.json) at the last successful sync

A side has "changed" iff its hash differs from lastSync. Hashes survive git clone/git pull mtime resets, so "changed since last sync" is reliable across machines — mtimes are consulted only to pick the winner of a genuine conflict (tie → home).

home repo Decision
unchanged unchanged noop
changed unchanged copy-home-to-repo
unchanged changed copy-repo-to-home
changed changed, same content adopt-baseline (converged — record, no copy)
changed changed, different conflict — newer mtime wins, loser saved to .conflicts/
missing unchanged delete-repo (deletion propagates)
unchanged missing delete-home (deletion from another machine)
missing changed delete-vs-change — repo wins, warning
changed missing delete-vs-change — home wins, warning
missing missing clear-meta

On first contact (no baseline) identical content is adopted silently; differing content is a conflict with the loser preserved — nothing is ever silently overwritten.

After all decisions are applied, the per-machine baseline is updated and the repo is committed (scoped to profiles/<active>/) and pushed. The baseline is never committed: each machine's "last synced content" is its own.

What's synced

Default include is ** (everything under ~/.claude/), filtered by the exclude list and an absolute built-in denylist.

Always excluded — built-in denylist (cannot be overridden)

Pattern What it blocks
.credentials.json Anthropic OAuth credentials
*.credentials.json Other credential files
*.key, *.pem, *.p12 Private keys
.env, .env.* Env files
*secret* Anything with "secret" in the name
*token* Anything with "token" in the name

Excluded by default (user can re-include)

Path Why
cache/**, paste-cache/**, downloads/**, stats-cache.json, mcp-needs-auth-cache.json, plugins/cache/**, plugins/plugin-catalog-cache.json, plugins/data/** Per-machine caches
daemon*, tasks/**, telemetry/**, jobs/**, security/**, security_warnings_state_*.json, *.bak.*, .last-cleanup Per-machine runtime state
projects/**, sessions/**, history.jsonl, file-history/**, shell-snapshots/**, session-env/** Per-machine project + session history (conflict-prone, can be many MB)
backups/** agentconf owns this directory

Synced by default

Everything else: settings.json, settings.local.json, CLAUDE.md, agents/, skills/, commands/, hooks/, output-styles/, plugins/ (including plugin code), statusline-command.sh, etc.

~/.claude.json key-level merge

The big ~/.claude.json file isn't synced wholesale — only 5 top-level keys are mirrored into claude.json.partial:

  • mcpServers
  • autoUpdates
  • autoUpdatesProtectedForNative
  • showExpandedTodos
  • showSpinnerTree

Everything else (oauthAccount, projects, theme, userID, firstStartTime, etc.) stays per-machine. Run agentconf describe to see exactly which keys live on your machine.

To add more keys, override in agentconf.yaml:

claude_json:
  include_keys:
    - mcpServers
    - autoUpdates
    - theme            # additional
    - editorMode       # additional

Auto-sync hooks

install-skill adds two entries to ~/.claude/settings.json:

{
  "hooks": {
    "SessionStart": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "timeout 3 agentconf sync --pull --quiet || true",
        "id": "agentconf-pull"
      }]
    }],
    "Stop": [{
      "matcher": "*",
      "hooks": [{
        "type": "command",
        "command": "agentconf sync --push --quiet --background",
        "id": "agentconf-push"
      }]
    }]
  }
}
  • SessionStart runs synchronously with a 3-second timeout. If the network is slow, sync silently gives up so Claude Code starts on time.
  • Stop runs in the background with a debounce window (10 seconds by default) — frequent stop events don't trigger redundant pushes.

Conflicts

When both machines edit the same file between syncs, the newer mtime wins. The losing version is never discarded — it's preserved in the repo at:

profiles/<active>/.conflicts/<path>.<host>.<iso-ts>

The sync exits with status 1 so you know to inspect. Resolve manually, delete the conflict file, and the next sync moves on.

Locks + debouncing

  • A file lock at ~/.agentconf/sync.lock prevents concurrent sync runs. A second invocation exits with status 2 and a "sync in progress" message.
  • The push debounce file ~/.agentconf/last-push.timestamp skips pushes that fire within push_debounce_seconds of the last one (default 10s).

What can go wrong

  • Remote unreachable — the local file merge still runs and commits; the push is skipped with a warning and retried on the next sync. Exit 0.
  • Non-fast-forward pull — when the divergence is this machine's own committed-but-unpushed sync commits, sync rebases them onto the remote automatically (remote history is never rewritten). If the rebase cannot resolve, sync aborts with exit 2.
  • Unrelated uncommitted changes in the checkout — sync aborts with exit 2 and lists them; commit or remove them first.
  • Malformed ~/.claude.json — the JSON key merge is skipped with a warning, the file merge still runs, and sync exits 1.
  • Push timeout — runs in background; check ~/.agentconf/logs/<date>.log for stack traces.
  • Conflicts pile up — inspect profiles/<active>/.conflicts/ and clean up after resolving.