Memory Mirror — cross-machine auto-memory sync (PR-gated)
Claude Code’s native auto-memory lives at ~/.claude/projects/<project-key>/memory/ and is machine-local. The memory mirror feature syncs only those memory/ subtrees across your fleet using gitfoam for push (~500ms latency per machine) and a lightweight main-only consumer on the sync daemon tick for pull (~60s).
Scope: memory only. Transcripts, session JSONLs, ctx_refresh_*.json snapshots, and todos/ are excluded by the rsync filter.
Topology
machine A machine B
───────── ─────────
~/.claude/projects/*/memory/ ~/.claude/projects/*/memory/
│ ▲
│ rsync (memory-only) │ merge — .conflict sibling on divergence
▼ │
~/.agentihooks/memory-mirror/ ~/.agentihooks/memory-mirror/
│ ▲
│ gitfoam force-push 500ms │ git fetch origin main every 60s
▼ │
gitfoam/A/main ┐ origin/main
│ ▲
└────── gh pr create ────────────────┘
(operator reviews + merges)
Each machine pushes to its OWN gitfoam/<hostname>/main branch. Nobody consumes anyone else’s branch directly. Everyone consumes origin/main. Promotion is a GitHub PR.
The mirror is identity-keyed (v3). Instead of storing memory under the raw Claude Code path encoding (-home-iamroot-dev-tccw-ecosystem-agenticore/...), the mirror uses by-project/<key>/memory/ where <key> is the repo or agent name — the same key on every machine, regardless of where the repo lives on disk. A resolver reverse-walks the filesystem and stops at the first package/agent boundary:
| Priority | Marker | Meaning |
|---|---|---|
| 0 | agent.yml | fleet agent boundary |
| 1 | pyproject.toml / Cargo.toml / package.json / go.mod | package |
| 2 | .git/ | repo root (fallback) |
Example: /home/iamroot/dev/tccw-ecosystem/agentihub/agents/finops/package → walks up past package/ (pyproject.toml) → hits finops/ (agent.yml) → identity = finops. A pod running the same agent at /app/finops/package also resolves to finops and they share memory.
Unresolvable paths (dir no longer exists, no marker found) land under _unmapped/<encoded>/ so nothing is lost.
Prerequisites
git,rsync,taron PATHghCLI (formemory-sync propose)- A private GitHub repo you own — agentihooks does NOT create it
gitfoambinary — either installed upstream or a local checkout pointed at byGITFOAM_LOCAL_SOURCE
Enable
1. Create the private repo
gh repo create <org>/claude-memory-mirror --private --confirm
2. Configure ~/.agentihooks/.env
MEMORY_MIRROR_MODE=write
MEMORY_MIRROR_REMOTE=git@github.com:<org>/claude-memory-mirror.git
# Optional (defaults):
# MEMORY_MIRROR_DIR=~/.agentihooks/memory-mirror
# MEMORY_MIRROR_BRANCH_PREFIX=gitfoam
# MEMORY_MIRROR_INTERVAL_SEC=60
# MEMORY_MIRROR_SWEEP_IDLE_DAYS=15
# GITFOAM_BINARY=~/.cargo/bin/gitfoam
# GITFOAM_LOCAL_SOURCE=/path/to/gitfoam # for `cargo install --path`
Legacy MEMORY_MIRROR_ENABLED=true (v1) is still accepted and maps to MEMORY_MIRROR_MODE=write.
3. Install
agentihooks memory-sync install
What this does:
- Verifies
ghand remote reachability - Builds or finds
gitfoam - Runs
ensure_mirror_repo(git init, add remote) - Seeds
origin/mainfrom your current memory (if main doesn’t exist yet) - Registers the mirror with
gitfoam init - Starts the gitfoam daemon (PID at
~/.agentihooks/gitfoam.pid)
4. Verify
agentihooks memory-sync status
# mode: write
# remote: git@github.com:<org>/claude-memory-mirror.git
# mirror: /home/you/.agentihooks/memory-mirror
# prefix: gitfoam
# interval: 60s
# sweep idle: 15d
# gitfoam: /home/you/.cargo/bin/gitfoam
# daemon: running (PID …)
Promoting a machine’s learnings to main
agentihooks memory-sync propose # open PR, review on GitHub, merge manually
agentihooks memory-sync propose --auto-merge # arm gh pr merge --auto --squash
propose compares gitfoam/<hostname>/main to origin/main; if they’re identical, it exits cleanly with “nothing to propose.” Otherwise it opens a PR with a short log summary as the body.
Roles (v4 — tiered trust)
Each node declares its role via MEMORY_MIRROR_ROLE. The role determines what happens in the tick loop.
MEMORY_MIRROR_ROLE | Snapshot | gitfoam push | Fetch main | Consume main | Push main | Propose |
|---|---|---|---|---|---|---|
off (default) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
consumer | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
offline | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
contributor | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ (manual) |
authority | ✓ | ✓ | ✓ | ✓ | ✓ (force-with-lease) | — |
EXACTLY ONE authority per fleet. Two authorities racing force-pushes will ping-pong on main; --force-with-lease prevents data loss but convergence slows. Enforce single-authority by operator convention — the code does not police it.
Consumer pods on a deployment mount can safely read fleet memory without ever writing to main; they skip gitfoam entirely (no daemon to run, no branch to push).
Authority is typically the operator’s laptop — its writes go straight to origin/main on each tick (~60s), so peers pick them up without a PR. Contributors continue to open PRs as before; the authority’s force-with-lease check makes those PR merges safe (if a peer PR lands between the authority’s fetch and push, the lease is invalidated and the authority retries next tick).
Legacy MEMORY_MIRROR_MODE (v3, still honored)
| Legacy MODE | Derived ROLE |
|---|---|
off | off |
write | contributor |
write-local-only | offline |
MEMORY_MIRROR_ENABLED=true (v1) | contributor |
When both ROLE and MODE are set, ROLE wins.
Authority setup
# On the operator's laptop only:
echo 'MEMORY_MIRROR_ROLE=authority' >> ~/.agentihooks/.env
agentihooks memory-sync install # idempotent — refreshes gitfoam/daemon
agentihooks memory-sync status # verify role: authority
Every ~60s the authority tick will:
- snapshot local memory into
by-project/<key>/ - fetch
origin/main - merge peer changes into
~/.claude/projects/*/memory/ - re-snapshot (picks up any
.conflictsiblings written in step 3) - force-with-lease push the commit back to
origin/main
If a peer PR merged between steps 2 and 5, the lease check fails server-side, the tick logs authority push lease invalidated, and the next tick tries again.
Consumer setup
# On read-only pods / deployments:
echo 'MEMORY_MIRROR_ROLE=consumer' >> ~/.agentihooks/.env
agentihooks memory-sync install # gitfoam build/install/start is skipped
The consumer daemon only ever calls git fetch origin main and merges into local memory. It never pushes.
Housekeeping
agentihooks memory-sync sweep-branches # uses MEMORY_MIRROR_SWEEP_IDLE_DAYS (default 15)
agentihooks memory-sync sweep-branches --idle-days 30
Deletes remote branches matching <prefix>/* that are:
- Already merged into
origin/main(viagit merge-base --is-ancestor), AND - Idle (no new commits) longer than the threshold.
Unmerged branches are never deleted. Safe to put on a daily cron.
Conflict model
origin/main evolves via PRs from many machines. When your local memory differs from main on the same file, the merge step writes the incoming (main) version to <name>.conflict-<hostname>-<epoch><ext> — your local file is never overwritten. Resolve via /memory, delete the conflict sibling.
Operations
agentihooks memory-sync start # start gitfoam daemon
agentihooks memory-sync stop # stop gitfoam daemon
agentihooks memory-sync sync-now # force one tick now (snapshot + fetch main + merge)
agentihooks memory-sync uninstall # stop daemon
agentihooks memory-sync uninstall --purge # stop daemon AND delete mirror dir
Logs:
- gitfoam daemon:
~/.agentihooks/logs/gitfoam.log - pull tick:
~/.agentihooks/logs/sync-daemon.log
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
MEMORY_MIRROR_REMOTE is not set | env var missing | Set it, re-run install |
cannot reach MEMORY_MIRROR_REMOTE | Repo doesn’t exist, SSH key missing, or wrong URL | gh repo create <org>/<name> --private --confirm; check SSH |
gh CLI not found (on propose) | Missing dependency | Install GitHub CLI: https://cli.github.com/ |
gitfoam not found on install | No binary, no local source | Install upstream OR set GITFOAM_LOCAL_SOURCE OR GITFOAM_BINARY |
| Transcripts appear on GitHub | Filter bug | Report immediately — P0 |
| Nothing pushes | gitfoam daemon not running | agentihooks memory-sync status → start |
| Main stays empty across machines | No one seeded it | Re-run agentihooks memory-sync install — seed is idempotent |
Migrating from v1/v2 layout
If you’re upgrading from a mirror that used the old raw-path layout (-home-iamroot-.../memory/ at the repo root), run:
agentihooks memory-sync migrate-layout # dry-run
agentihooks memory-sync migrate-layout --confirm # execute
This stops gitfoam, force-pushes a fresh main with the v3 by-project/<key>/ layout from your current machine’s snapshot, and deletes all old gitfoam/* / proposal/* branches. gitfoam restarts and republishes your gitfoam/<hostname>/main under the new layout on its next push.
Known limitations
- Basename collisions. Two different repos with the same folder name (say, two unrelated
publisher/repos in different orgs) will pool into one mirror key. Your current fleet has no collisions; flag if this ever changes. - Multi-tenant writers on a single machine race at the filesystem. Ten pods sharing one mount all writing to the same
MEMORY.md→ last writer wins at the OS layer, before git sees anything. Upstream problem. - Propagation latency = PR review latency. Machines see each other’s writes only after a PR is merged to main. By design.
- Tombstone-free deletion. A file deleted on machine A is still on main; machine B will re-push it. Until a tombstone mechanism lands, prefer editing over deleting.
Design notes
- Push delegated to gitfoam — handles per-host branch naming, secrets scanning, force-push throttling.
- Pull runs inside
scripts/sync_daemon.py’s existing poll loop. No new daemon. - PR gate via
gh pr create— no server-side component. - Seed step uses
git commit-tree+git update-refso it doesn’t touch gitfoam’s working branch, avoiding any race with the 500ms push loop.