У меня три рабочих места с пятью ОС: десктоп на Linux и Windows, ноутбук также с Linux и Windows, а ещё мак мини на macOS. Claude Code стоит на всех, и каждый раз повторялось одно и то же: разговор, который я вёл на десктопе, оставался на десктопе. Сажусь за ноутбук — Клод не помнит ни проекта, ни договорённостей, скиллы не те, MCP-серверы не подключены, память пустая.

Очевидное решение — положить ~/.claude в облачную папку, например в свой Яндекс.Диск, — не сработало. Почему именно не сработало, я понял не сразу, и это оказалось самой интересной частью задачи. Дальше — разбор граблей, на которые я наступил, пока делал синхронизацию по-настоящему.

Один разговор продолжается на другой машине
Один разговор продолжается на другой машине

Всё описанное лежит в открытом репозитории, ссылка в конце.

Почему не облачная папка

Первое, обо что спотыкаешься, — конфликты. Claude Code дописывает транскрипт сессии после каждого ответа модели. Если папка синхронизируется между двумя машинами, вы получаете непрерывный поток конфликтов, а облачные хранилища разрешают их по принципу «кто последний записал» — реплики просто исчезают.

Но это меньшая беда. Большая обнаруживается, когда вы всё-таки донесли файл до второй машины и открываете сессию. Транскрипт набит абсолютными путями машины, которая его писала:

/home/alex/projects/MyApp/app/build.gradle
/home/alex/.claude/skills/...

На маке этот проект лежит в /Users/alex/Dev/My Apps/MyApp, на Windows — в D:\Projects\MyApp. Вычислить одно из другого нельзя: это разные пути, а не разные префиксы. Скопировать байты — не значит сделать их пригодными.

Отдельно: ~/.claude — это сотни мегабайт живого состояния. Кэши плагинов, снимки шелла, ключи доступа, history.jsonl. Класть это в git целиком — значит конфликтовать на каждом pull и утечь секретами на первом push.

Значит, нужен слой, который понимает, что именно переносит.

Как Claude Code хранит сессии

Отдельной базы или индекса нет — источник истины сами файлы:

~/.claude/projects/<слаг-проекта>/<uuid-сессии>.jsonl

Слаг получается из рабочего каталога простой заменой:

slug = re.sub(r'[^A-Za-z0-9]', '-', cwd)

Я сверил правило со всеми папками на своей машине, включая каталог с кириллицей и каталог с пробелом в имени, — совпало везде. Внутри .jsonl — по одной JSON-записи на строку, у каждой записи есть uuid, cwd и само сообщение.

Отсюда следует главное допущение всей затеи: если положить корректный .jsonl в правильную папку, /resume его увидит. Я проверил это первым делом, до того как писать что-либо всерьёз, — потому что если бы не взлетело, вся конструкция не имела бы смысла. Спойлер: взлетело.

Грабля первая: сессия принадлежит каталогу запуска

Первая версия определяла проект по текущему рабочему каталогу — казалось очевидным. Через день я обнаружил в хранилище одну и ту же сессию дважды, под разными ключами: застывший огрызок и продолжение.

Оказалось, Claude Code кладёт транскрипт в папку того каталога, где сессия была запущена, и не переносит файл, когда вы в ходе разговора переходите в другой проект. То есть cwd в момент push и место файла — разные вещи.

Правильный ключ берётся из самого транскрипта: читаем первую запись с полем cwd и проверяем, что слаг от него совпадает с именем папки, в которой файл лежит.

def origin_path(transcript: Path, max_lines: int = 40) -> str | None:
    """Каталог, в котором сессия была ЗАПУЩЕНА."""
    for line in first_lines(transcript, max_lines):
        candidate = json.loads(line).get("cwd")
        if candidate and slug_for(candidate) == transcript.parent.name:
            return candidate
    return None

Пока я это чинил, выяснилось, что у дублей есть и второй источник. Если проект на машине ещё не привязан к своему пути, сессия раскладывается в запасной каталог ~/claude-sessions/<ключ>. Привязали проект позже — прежняя раскладка осталась лежать. На ноутбуке у меня накопилось четыре копии одной сессии.

Уборка таких хвостов делается с оглядкой: копия удаляется, только если все её записи по uuid есть в той, что остаётся. Если в старой копии нашлось своё — она остаётся, и об этом печатается предупреждение. Терять диалоги в угоду аккуратности недопустимо.

Грабля вторая: пути надо не переносить, а переписывать

Раз пути на машинах не связаны, соответствие задаётся явно — по файлу на машину:

{ "machine_id": "linux-desktop",
  "paths": { "myapp": "/home/alex/projects/MyApp" } }

При выгрузке пути сворачиваются в токены, при загрузке разворачиваются под текущую машину:

В транскрипте на машине

В репозитории

/home/alex/projects/MyApp/app/build.gradle

{{P:myapp}}/app/build.gradle

/home/alex/.claude/settings.json

{{HOME}}/.claude/settings.json

D:\Projects\MyApp

{{P:myapp}} (+ разделители и буква диска на обратном пути)

Замены применяются от самого длинного пути к короткому — иначе вложенный путь съест часть внешнего.

Отдельная тонкость с настройками. Пути в settings.json нельзя подставлять в текст: строка вида C:\Users\alex внутри JSON-строки — невалидная escape-последовательность, файл разваливается. Поэтому JSON обрабатывается по узлам, а не как текст.

Что делать, если проекта на новой машине нет вовсе? Ничего не ломать. Сессия раскладывается в запасной каталог, открывается и читается — просто рядом нет файлов проекта, и об этом честно сказано в выводе.

Грабля третья: общий файл — это гарантированный конфликт

Сначала реестр машин и карта проектов были двумя общими файлами: machines.json и project-map.json. Всё работало ровно до первого раза, когда две машины поработали не синхронизируясь. Дальше — both modified, и разбирай руками.

Причём конфликт был бессмысленным: одна машина писала про себя, вторая про себя, пересечения данных нет. Конфликтовал сам факт общего файла.

Лечится разбиением по владельцу:

machines/linux-desktop.json
machines/work-laptop.json
project-map/linux-desktop.json
project-map/work-laptop.json

Каждая машина пишет только свой файл. Git сливает такое молча, потому что конфликтовать больше нечему. Тот же приём — «операция как создание файла» — я потом применил ещё дважды: для отметок об удалённых сессиях и для подтверждений от машин.

Осталась одна общая вещь — шаблоны в tools/ (настройки, список MCP). Их сливает трёхсторонний merge по узлам JSON: берём общего предка, свою версию и чужую из индекса git, и при споре побеждает локальное значение. Всё, что не JSON, остаётся человеку: файл памяти или скилл, изменённый на двух машинах по-разному, за вас сливать нельзя.

Грабля четвёртая: память нельзя делать общей целиком

Память Claude Code — это факты, которые агент накапливает о вас и проектах. Казалось бы, синхронизируй всё. Но факты неравноценны:

  • «Основной язык проекта — Kotlin» — верно везде;

  • «Cisco Secure Client установлен и ломает /lib при рестарте» — верно ровно на одной машине.

Если разнести второй факт на все машины, агент начнёт уверенно применять его там, где он не имеет смысла. Это хуже, чем отсутствие факта.

Поэтому у каждого факта во frontmatter есть scope:

metadata:
  type: project
  scope: global            # или linux-desktop, или os:linux, или [a, b], или !work-laptop

А MEMORY.md — файл-индекс, который агент читает при старте, — генерируется на каждой машине свой: раздел «общее», раздел «про эту машину» и отдельно, с явной пометкой «не применять здесь без проверки», перечень чужих. Файлы чужих машин физически доступны — если я прямо спрошу про мак, Клод их прочитает, — но в рабочий контекст они не попадают.

Тот же механизм скоупов пригодился для MCP-серверов: сервер, завязанный на локальную базу или собранный из исходников бинарь, помечается как «не для этой машины» и не приезжает туда, где будет молча падать при каждом старте.

Грабля пятая: слияние настроек без базы — это не слияние

Трёхсторонний merge требует трёх версий: предка, локальной и входящей. Предка мы храним сами — слепок шаблона, сделанный при прошлом применении.

А что происходит на машине, где этого слепка ещё нет, а settings.json уже есть? Кода на такой случай не было, и он делал буквально следующее:

merged = incoming if base is None else merge_json(base, local, incoming)

То есть первый же pull заменял настройки целиком. Прежние уходили в .bak, но человек, который только что подключился, видел, что у него сменились модель и тема.

Я это поймал на стенде, который специально кладёт на «чистую машину» чужой CLAUDE.md и settings.json со своими ключами и проверяет, что они переживут подключение. Лечение в итоге оказалось не в коде, а в порядке шагов: заготовка не везёт с собой шаблон настроек вовсе, а хуки прописывает отдельный скрипт прямо в локальный файл — после чего первый push уносит их в хранилище уже как общий шаблон.

Грабля шестая: приватный разговор надо помечать заранее

Иногда разговор не должен уезжать никуда. Для этого есть пометка «не синхронизировать», и с ней связано неприятное свойство, о котором лучше говорить прямо: фоновый хук отправляет транскрипт каждые несколько минут. Если спохватиться через полчаса, копия уже в хранилище.

Поэтому команд две. Первая помечает сессию, чтобы она больше не уезжала. Вторая — «забыть везде»: удаляет копию из хранилища, оставляет отметку-надгробие, по которой остальные машины снесут свои копии при ближайшем pull, и удаляет локальный файл.

Две детали, которые пришлось предусмотреть:

  • если забываем текущую сессию, локальный файл удалять бессмысленно: Claude Code пишет в него прямо сейчас и создаст заново. Удаление откладывается до закрытия сессии;

  • отметки нельзя копить вечно. Они убираются, когда все известные машины подтвердили удаление, — а подтверждение это опять же создание пустого файла, чтобы не конфликтовать.

И то, о чём я написал в README отдельным абзацем: удаление делается обычным коммитом, история git не переписывается. В клонах, сделанных раньше, старые объекты остаются.

Как это проверялось

Тестировать синхронизацию между машинами обычными юнит-тестами бессмысленно — ломается всё на стыках. Поэтому проверки сделаны стендами: каждый поднимает собственный $HOME, собственный bare-репозиторий и разыгрывает сценарий целиком.

Что проверяют три стенда движка (64 проверки):

  • приватные сессии: пометки, отложенное удаление, отметки для других машин;

  • расхождение машин, работавших вне сети: авторазрешение конфликтов, миграция старых реестров, списание машины;

  • ключи сессий: смена каталога, выбор живой копии из нескольких, уборка прежних раскладок.

Отдельно — стенд самой заготовки (24 проверки): он проводит две одноразовые машины по всему пути «первая машина → вторая машина» и убеждается, что ничей CLAUDE.md, настройки и скиллы по дороге не затёрлись. Именно он поймал грабли номер пять.

Пример проверки, чтобы было понятно, о каком уровне речь:

check "CLAUDE.md остался своим" "1" "$(grep -c 'МОИ ПРАВИЛА' "$BASE/a/.claude/CLAUDE.md")"
check "хуки уехали токенами, без путей машины" "4" \
    "$(grep -c '{{PYTHON}} {{VAULT}}/bin/cchook.py' "$vault/tools/settings.template.json")"

Мелочь, которую я померил и не стал чинить

Движок пересобирает каждую запись транскрипта через json.dumps — и делает это с отступами по умолчанию, хотя Claude Code пишет компактно. Выглядит как явная потеря: лишние пробелы в каждой строке многомегабайтного файла.

Посчитал на своих реальных сессиях: 17,03 МБ против 16,69 МБ. Два процента — основной объём это текст диалога, а не структура. Правка на одну строку, но каждая правка движка — это цикл выпуска и повторный прогон стендов. Не стоит того.

Пишу об этом, потому что «нашёл неоптимальность — почини» звучит убедительно ровно до момента, когда её измеришь.

Ограничения, честно

  • Всё держится на формате .jsonl, который недокументирован. Anthropic может его изменить.

  • Машины должны быть на близких версиях Claude Code: старая сборка может не прочитать сессию с более свежей. status предупреждает, если где-то версия новее.

  • Одновременная работа в одной сессии с двух машин не поддерживается (не было такой цели). Транскрипты помечены merge=union, так что ничего не потеряется, но порядок реплик может поехать.

  • Это git, а не realtime: задержка измеряется минутами.

  • Нативная Windows поддержана — хуки не содержат команд шелла, JSON собирается по узлам, симлинки при недоступности заменяются копиями, — но обкатана заметно меньше, чем Linux и macOS.

Одна команда на новой машине — и всё на месте
Одна команда на новой машине — и всё на месте

Ссылка

Репозиторий: github.com/Cha1000000/claude-code-sync

Это шаблон, а не сервис: нажимаете «Use this template», делаете свою копию приватной и дальше работаете с ней. Форк здесь не подходит — форк публичного репозитория на GitHub всегда публичный, приватным его сделать нельзя, а в репозитории будут лежать ваши транскрипты.

Кстати, про форки. Я хотел просто отключить их у себя, чтобы исключить недоразумения, и обнаружил, что GitHub не позволяет: настройка allow_forking доступна только приватным репозиториям внутри организаций. На публичном личном репозитории форки отключить невозможно :( Пришлось заменить настройку политикой — небольшой workflow закрывает сторонние pull request’ы с объяснением.

Движок — чистый Python 3 без зависимостей. Вывод локализован: английский по умолчанию, русский по локали или через CCSYNC_LANG.


Отдельно замечу: большая часть кода написана в паре с самим Claude Code — что, кажется, уместно для инструмента, который синхронизирует Claude Code. Грабли от этого никуда не делись: все шесть описанных выше найдены не при написании кода, а при попытке им пользоваться.