У меня три рабочих места с пятью ОС: десктоп на 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" } }
При выгрузке пути сворачиваются в токены, при загрузке разворачиваются под текущую машину:
В транскрипте на машине | В репозитории |
|---|---|
|
|
|
|
|
|
Замены применяются от самого длинного пути к короткому — иначе вложенный путь съест часть внешнего.
Отдельная тонкость с настройками. Пути в 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. Грабли от этого никуда не делись: все шесть описанных выше найдены не при написании кода, а при попытке им пользоваться.