DEV Community

Rishi Gulati
Rishi Gulati

Posted on AI-assisted

Syncing Claude Code and Codex sessions across machines with git

I work across three laptops. Claude Code and Codex both keep every conversation on the machine that started it, which means the session where the agent finally understands your codebase is stuck wherever you happened to be sitting last Tuesday.

Copilot's answer is account-level cloud sync. I didn't want transcripts of my code sitting in a vendor's cloud, and I didn't want to run a server or pay for S3 either. The infrastructure I already own, trust, and use daily is a git remote. So I built repo-sessions: sessions sync through one private git repo (a "vault"), and the sync triggers ride the git push and pull you already do. Push on laptop A. On laptop B, pull, then claude --resume. The conversation is there.

This post is the story of building it, told mostly through three bugs. Each one looked like a small edge case and turned out to be structural.

Why you can't just copy the files

Claude Code stores each session as a JSONL file under ~/.claude/projects/<munged-path>/<session-uuid>.jsonl. The munged path is your project's absolute path with every character outside [A-Za-z0-9-] replaced by a dash:

export function mungeCurrent(absPath: string): string {
  return absPath.replace(/[^A-Za-z0-9-]/g, '-');
}
Enter fullscreen mode Exit fullscreen mode

So /Users/rishi/dev/proj becomes -Users-rishi-dev-proj. On top of that, every single line of the transcript embeds your cwd, plus the session id, tool version, and git branch.

Two consequences. First, resume looks sessions up by the munged path of the directory you're standing in, so on another machine (different username, different clone location) the file is in a directory Claude Code will never compute. Second, even if you put the file in the right place, the embedded paths point at directories that don't exist there.

I verified all of this empirically before writing any real code, because none of it is documented. The method: start a session that establishes a codeword, hand-copy the transcript to a second location with paths rewritten, and try to resume. The negative control ran first: resuming on "machine B" without the rewrite says No conversation found, which is exactly the failure this tool exists to fix. With the rewrite, the resumed session recalled the codeword and reported machine B's path as its cwd.

The spike also surfaced something uncomfortable: the munge rule has drifted across Claude Code versions. The same directory on my machine had transcripts under both -AI-AI-ML and -AI-AI_ML, meaning some older build preserved underscores. So the locator scans all known rule variants instead of computing one directory name and trusting it. When you build on undocumented formats, you inherit their history too.

The pipeline

On push, every absolute path in the transcript becomes a token: ${CSS_PROJECT_ROOT}, ${CSS_HOME}, and the munged dirname (which shows up whenever tool output references ~/.claude/projects/...). On pull, tokens are rewritten for the local machine. Codex needs a third path surface, because its rollout files contain self-references to $CODEX_HOME.

The vault is deliberately boring: one private git repo, plain directories, one namespace per project keyed by a hash of the normalized origin URL. Any git UI can inspect it. Sync triggers are git hooks (pre-push sends sessions out, post-merge and post-checkout bring them in), and existing hooks are chain-loaded first with their stdin and argv intact, so your husky setup keeps working and its failures still abort the git operation. Mine never do: if the vault is unreachable, everything behaves as if the tool doesn't exist.

Bug one: Windows backslashes inside JSON

Symptom, found by dogfooding on my actual Windows laptop: a synced session opens, but only the last few turns render.

Cause: transcripts are JSON, and I was substituting paths into raw bytes. Splice C:\Users\rishi\dev\proj into a JSON string literal and you've written \U, which is not a valid escape sequence:

{"command":"ls C:\Users\rishi\dev\proj"}     <- unparseable line
{"command":"ls C:\\Users\\rishi\\dev\\proj"} <- what should be on disk
Enter fullscreen mode Exit fullscreen mode

Claude Code fails to parse the corrupt lines, the parent-uuid chain linking turns together breaks, and the UI shows you whatever survived. So all transcript substitution now works on the JSON-escaped spellings: the tokenizer matches every spelling of a path (escaped, forward-slash, either drive-letter case, longest first so escaped forms are consumed before their shorter cousins), and rehydration emits the escaped form.

Same family, same week: Windows git's autocrlf rewrote LF vault files to CRLF on checkout, which read as phantom content changes in synced memory files (fix: * -text in the vault plus EOL-insensitive comparison). And git's default 1 MiB http.postBuffer was rejecting multi-megabyte transcript pushes over https with an opaque HTTP 400, so background pushes failed silently, the worst possible failure mode for a sync tool.

The lesson so far: the same content has more byte representations across machines than you think. I thought I'd learned it. The next bug was the same lesson with teeth.

Bug two: the session that quoted its own sync metadata

A month in, one session started reading as permanently diverged on every pull, on both machines, even when I knew nothing had changed. Naturally it was the session where I was using Claude to debug this tool.

Here's the mechanism. Tokenization is machine-relative: my Mac's tokenizer knows the Mac's paths. But this transcript quoted my Windows machine's path spellings as ordinary message text, pasted terminal output, a munged dirname like C--Users-rishi-..., chunks of a metadata file. When the Mac pushed, those strings went into the vault verbatim, because to the Mac they're not paths, just text. The Windows machine's tokenizer would have collapsed them. So no tokenization the Mac can ever perform reproduces the vault bytes, the hashes disagree forever, and the sync layer concludes the session has diverged.

Then it got worse. The reconcile command finds the common prefix of the two copies at the byte level. The first quoted spelling appeared around line 62, so it found a false fork point there and spliced everything after it back on as a "divergent tail". Each run of the repair grew the transcript: 471 lines, then 881, then 1701, with some turns duplicated eight times. Watching a repair command make the patient worse is a special feeling.

The fix has two parts. Every push now records that device's path context (project root, home, tool data dir) in the vault index. And comparisons never touch raw bytes: both sides get folded through the tokenizer under every device context the vault has seen, in a deterministic order.

export function canonForCompare(adapter: Adapter, content: string, ctxs: PathCtx[], opts?: SubstOpts): string {
  let out = adapter.stripVolatile ? adapter.stripVolatile(content) : content;
  for (const c of ctxs) out = adapter.tokenize(out, c, opts);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Tokens are inert under further tokenization, so the fold is a projection, applying it twice equals applying it once. That property is a unit test, straight from the incident:

const once = canonForCompare(claudeAdapter, fromA, ctxs, { json: true });
const twice = canonForCompare(claudeAdapter, once, ctxs, { json: true });
expect(once).not.toBe(fromA); // the fold genuinely collapses foreign spellings
expect(twice).toBe(once);     // and it is a projection
Enter fullscreen mode Exit fullscreen mode

Just as important: the normal form is compare-only. The vault always stores the writer's own tokenization and installs always rehydrate the raw stored bytes. Comparison logic can evolve without ever rewriting anyone's data.

Bug three: the tool edits the transcript behind your back

Weeks later, false divergence came back, and this time the transcript quoted no foreign paths at all.

It turned out Claude Code appends a title-refresh record ({"type":"ai-title",...}) roughly every turn, on whichever machine is currently live. Two honest copies of the same conversation end up with different numbers of these records at different positions. Content-wise one copy was a clean prefix of the other, just behind. Byte-wise, diverged.

So the normal form grew a second axis: before the path fold, adapters can strip records the tool rewrites non-deterministically. The strip is parse-confirmed, only a line whose top-level JSON type is ai-title is dropped, so a turn that quotes such a record as data survives. Which mattered immediately, because the debugging session quoted them constantly.

The general lesson landed harder than either bug alone: a compare normal form has to normalize every axis of benign nondeterminism, and every new record type the vendor ships can reintroduce this bug class. That's the treadmill you're on when you build against undocumented formats. My concession to it is a doctor command that verifies drift the only way that can't lie: by actually resuming a session and checking the tool responds.

The rules that survived

Three principles came out of this intact and now gate every feature:

Transcripts are append-only. Prefix means fast-forward, divergence means conflict copies, and chat rebase splices tails back into one transcript ("meanwhile, on the other laptop", which is the truth). Nothing is ever silently dropped. Rewind is a fork from vault history, never an in-place truncation, because other devices would just push the "missing" turns right back. Deleted sessions restore from vault history, since the vault is git and every synced state is a commit.

Never write another tool's database. Codex indexes sessions in SQLite alongside the rollout files. I don't touch it. Drop a rewritten rollout into the date tree and the index heals itself on first resume. Slightly worse UX (the interactive picker lists a synced session only after you've resumed it by id once), massively better failure modes.

Degrade to invisible. Vault unreachable, tool not set up, repo not enabled: every hook exits quietly and the coding tools behave exactly as if repo-sessions were never installed.

What it looks like now

npm i -g repo-sessions
chat setup   # once per machine: creates or reuses your private vault
chat init    # once per repo: from here git push/pull carry your sessions
Enter fullscreen mode Exit fullscreen mode

Then work normally. Sessions and per-project memory follow the repo. chat list, chat resume <name>, chat rebase when you got ahead of yourself on two machines at once, chat restore when you deleted something you shouldn't have.

(The binary is named chat. macOS ships a pppd utility from the dial-up era at /usr/sbin/chat, and a shell opened before the install will happily run that one instead, which blocks on stdin and looks exactly like "the tool does nothing". hash -r. Ask me how I know.)

It's MIT licensed, TypeScript with zero runtime dependencies, 61 hermetic tests running on a 3-OS CI matrix, and dogfooded daily across three machines, including a 400k-token session that resumed cross-OS with an accurate recap. One honest caveat: transcripts contain whatever your session saw. The vault must be private (setup refuses public GitHub vaults), pushes run a gitleaks-style secret scan, and there's a per-session ignore file, but you should treat the vault the way you treat .env.

What I'd tell you to steal

  1. Byte equality is the wrong identity for synced content. You need equality up to every writer's encoding, and you should expect the set of writers to grow.
  2. If synced content can quote your own sync metadata back at you, it eventually will. Any session about the tool itself is a self-referential input, and dogfooding guarantees those exist.
  3. Normalize at compare time, never at rest. A compare-only normal form can evolve freely because it never rewrites stored data.
  4. When there are no docs, a codeword and a negative control settle in one evening what speculation never will.

Repo: https://github.com/firish/repo-sessions

If you try it, the failure modes I haven't found yet are the ones I most want to hear about.

Top comments (0)