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 inprofiles/<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:
mcpServersautoUpdatesautoUpdatesProtectedForNativeshowExpandedTodosshowSpinnerTree
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:
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.lockprevents concurrent sync runs. A second invocation exits with status2and a "sync in progress" message. - The push debounce file
~/.agentconf/last-push.timestampskips pushes that fire withinpush_debounce_secondsof 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>.logfor stack traces. - Conflicts pile up — inspect
profiles/<active>/.conflicts/and clean up after resolving.