{"slug": "the-session-that-synced-itself", "title": "The session that synced itself", "summary": "A developer built claude-code-sessions, a CLI tool that copies Claude Code conversation sessions between Claude accounts on the same machine, after discovering that transcript files contain no account identifier and that account listings are just pointers to shared transcripts. The tool, which refused several operations for correct reasons, successfully synced a session end-to-end on 2026-08-03, with a native row shrinking from 132,264 bytes to 715 bytes when stripped of account-specific data.", "body_md": "Last night I did something mildly recursive. Mid-conversation, I had the Claude Code\nsession I was working in copy *itself* — the conversation you'd click on in the sidebar —\nfrom one Claude account into another on the same machine. Then I switched accounts and\ntyped my next message from the other side. The conversation picked up where it left off:\nsame history, same working state, now running under the destination account's own\nconnectors and permissions rather than the source's.\n\nThe tool that did it is [claude-code-sessions](/craigstoller/claude-code-sessions/blob/main/README.md), a small CLI I built after\nspending a week measuring what Claude actually keeps on disk. Before it made that copy,\nit refused me — several times, for several different reasons, every one of them correct.\nBy the end of the evening all four of its refusal types had fired in real use, and the\nsingle mutation that *should* have succeeded did, on the first cleanly-permitted attempt.\n\nThis post is about what's really in those files, and about why a tool that touches them should be built mostly out of refusals. Everything in it was measured on one Windows machine, on current builds, with methods and caveats collected at the end — including the measurements that turned out to be wrong.\n\nTwo programs share the picture, and it helps to name them precisely. **Claude Code** — the\nCLI — writes the conversation itself: a transcript at\n`~/.claude/projects/<project-folder>/<session-id>.jsonl`\n\n(sessions that spawn subagents\nget a sibling folder of their transcripts too). **Claude Desktop** — the app with the\nsidebar and the account switcher — keeps, per account, a store of \"listing rows\": one\nsmall JSON file per conversation, holding the title, timestamps, the session id, and a\nsnapshot of per-session configuration. The sidebar is that directory listing.\n\nHere is the fact that makes cross-account sync possible, and it surprised me when I\nmeasured it: **no account identifier appears anywhere in the transcript.** The\nconversation file doesn't know who was signed in when it was written. What an account\n\"has\" is its listing row — and the row's only load-bearing part is a pointer to the\nshared transcript.\n\nSo \"moving a conversation to another account\" doesn't move the conversation at all. It\nplaces a row in a different account's store; that account's sidebar shows the thread;\nclicking it opens the shared transcript; the conversation resumes. I verified the resume\nend-to-end before building anything — on 2026-07-31, rows copied by hand because the tool\ndidn't exist yet, in the desktop app and with `claude -r`\n\n, with a native-thread control\nalongside so a broken invocation couldn't masquerade as a finding. (The shipped tool's\nown end-to-end run, new turns included, came 2026-08-03 — it's the story this post ends\nwith.) Why Anthropic's backend is content to continue a session under\neither account is a question I can only observe the answer to, not explain: this project\nmeasured the local files, nothing past them.\n\nTwo numbers make the pointer-ness vivid. A native row can be big — the largest on my\nmachine was 1.36 MB, mostly a snapshot of the account's connector and MCP-tool\nconfiguration. Stripped of everything account-specific, one 132,264-byte row came down to\n**715 bytes**, and the thread still opened perfectly. The first time the destination\naccount focused it, the app rewrote the row back to full size, repopulating the connector\nsnapshot from *that account's own* configuration. The row you sync is a seed; the app\ngrows it back on the receiving side. Two honest limits to the metaphor: the copy's title\nand sort position freeze at sync time (re-running `sync`\n\nwon't refresh an existing row —\nan `--update`\n\nmode is future work), and the moment the destination opens the thread, the\nrewritten row no longer matches what the tool journaled — which, as you'll see, is\nexactly when the tool stops being willing to undo it.\n\nMeasuring the store turned up three places where what the app records and what the app does are different things. Each one changed the tool's design.\n\n**It writes deletion records it doesn't honour.** Delete a thread in the sidebar and the\napp drops a small `deleted_<id>`\n\nmarker — thirteen bytes, an epoch-milliseconds timestamp\n— into the account's store. You'd assume that marker suppresses the thread. On the builds\nI measured, nothing consumes it: put a listing row back and the \"deleted\" thread\nreappears, tombstone still sitting there, across restarts and an account switch. Which\nmeans a naive row-copying tool will *resurrect threads the user deliberately deleted* —\nthe single worst thing a sync tool could quietly do. So my tool honours the app's\ntombstones even though the app itself doesn't: a tombstoned session is never synced into\nthat account unless you name it explicitly.\n\n**The deletion records come in at least two id spaces.** After I'd built that check, I\nfound a deleted thread carrying *two* markers: one under its session id, one under the\nlisting row's own filename stem. My first implementation checked only the space I'd\nobserved; a deletion recorded the other way would have been invisible, and the thread\nresurrected anyway. This was the second time in the project a measured fact turned out to\nbe narrower than the measurement — I'd seen *a* tombstone and generalized its naming from\none example. Measure, then assume your measurement is a special case.\n\n**Its two identity files have two different owners.** Who's signed in? There are two\nanswers on disk: the CLI's `~/.claude.json`\n\nnames an account (`oauthAccount`\n\n), and the\ndesktop's own `config.json`\n\nnames one (`lastKnownAccountUuid`\n\n). I assumed the oauth\nrecord was authoritative and the desktop's field was the stale-prone fallback — and\nshipped a safety model that trusted it accordingly. Then I measured across a real account\nswitch: the desktop's field tracked the switch correctly and it was **the oauth record\nthat went stale**, still naming the account I'd switched away from. The simplest\nexplanation, consistent with everything observed but not separately proven: each program\nwrites only its own file, so signing in on the desktop gives the CLI's record no reason\nto notice. A review had already constructed the mirror-image failure — desktop field\nwrong, oauth fresh — in a synthetic store. One direction measured in the wild, the other\ndemonstrated in review; the design treats both as live. When the two files disagree, the\nhonest answer to \"which account is live?\" is *you cannot know from files*.\n\nThat conclusion set the design rule for the cross-account commands: **no cached identity\nfact is allowed to be load-bearing for safety.** The one catastrophic act a tool like\nthis could commit is mutating the store the app is *actively using* — the app and the\ntool racing on the same files. Everything else is journaled: copies are additive-only,\nand undo holds for as long as the copy sits unchanged. So for anything that writes or\ndeletes across the account boundary, the guards stack:\n\n**If the identity files disagree, planning refuses.** No mutation destination resolves at all. The refusal names both accounts and the two actions that reconcile them: re-authenticate the CLI as the account you're using, or switch the desktop to it. (Signing into the desktop does*not*refresh the CLI's file — that's the trap I fell into.)**If the destination resolves to the live account — or to**, writing or deleting, before or after a crash.*either*account a disagreement names — it refuses**If the desktop app is running, every mutation refuses**, whatever the identity files claim — and the refusal lands*before*anything is journaled, so a refused run leaves zero cleanup. The process check fails closed: an unreadable process list counts as \"possibly running,\" never as \"all clear.\" A running Claude Code*CLI*is recognised by its install path and doesn't block the sidebar-store commands — it doesn't write those stores.**If a row changed since the tool journaled it, undo refuses to delete it.** The destination account may have opened that thread; a byte-for-byte match is the only license to remove. Since the app rewrites a row on first focus, this means undo's window closes the moment the synced thread is actually opened — deliberately. Past that point, deleting it is a decision for a human in a sidebar, not a tool in a journal.\n\nResiduals, because absolutes are how this kind of post loses your trust: the process\ncheck is point-in-time (an app *started* mid-write isn't caught — the drift and overwrite\nrefusals are the second layer), and for `move`\n\n— which relocates transcripts that a\nrunning CLI session genuinely does write — excluding the CLI from the process check means\na transcript-freshness heuristic and a last-instant content re-verification carry that\ncase. Scoped claims, stated residuals; the [internals doc](/craigstoller/claude-code-sessions/blob/main/docs/internals.md) enumerates them.\n\nI want to be honest about how rule 2 got its second clause, because it's the strongest\ncase I know for adversarial review. My disagreement fix made the account-resolver return\n\"unknown\" — and two older checks read \"unknown\" as \"no evidence, proceed,\" which quietly\ndisabled the most load-bearing refusal in the design *in exactly the state the fix\nexisted to handle*. A final review that mutation-tested the code caught it and reproduced\nthe failure end-to-end: at the previous release both operations refused; with my fix, a\ncrash-recovery wrote into a possibly-live store and an undo deleted from one. The fix for\nthat fix is why the ladder reads the way it does. The test suite grew from 232 to 267\nalong the way, and several of those tests exist because a reviewer proved an earlier test\nwould still pass with the feature deleted.\n\nOne more thing the guards taught me about the app itself: after you close Claude Desktop,\na helper it ships for its Chrome extension — `chrome-native-host.exe`\n\n— can keep running\nunder the desktop's own package directory. The guard counts it (its write behaviour is\nunmeasured, so: fail closed), which means \"close the app\" is sometimes not enough — you\nhave to fully exit Chrome too. The refusal names the surviving process, so you can see\nwhy you're being refused.\n\nWhich brings me back to the recursive part. To run the last verification I needed to\nswitch desktop accounts — but the session driving the work existed only in the *current*\naccount's sidebar. Switching would strand it. The obvious fix was the tool itself: sync\nthe session across first. And here the design bit its own author: the identity files\ndisagreed at that moment — the measured staleness, live on my machine — so the tool\n**refused to plan the very sync that would have rescued my session**. Correctly: under\ndisagreement it cannot prove which store is dormant.\n\nSo: re-authenticate the CLI. First `--apply`\n\n: refused — the Chrome helper was still\nalive, and the pre-journal guard declined with nothing written and nothing to clean up.\nExit Chrome fully, probe clear, re-run: seven rows written from one account's store into\nthe other's, journaled, one op. Reopen the desktop, switch accounts — and there's the\nconversation in the other sidebar, where my next message became the end-to-end proof\n(2026-08-03, now recorded in the repo's compatibility matrix): the session continuing,\nlive, from the far side of its own migration.\n\nMeanwhile the disposable test thread I'd spent the week trying to `undo`\n\nended the\nevening untouched — protected, in sequence, by the disagreement refusal, the\nlive-destination refusal, the running-app refusal, and finally the drift refusal, because\nthe receiving account had opened it once. Four refusals, four different reasons, every\none of them the design working. For a tool whose worst failure mode is deleting someone's\nconversations, that's the property I care most about.\n\n[claude-code-sessions](https://github.com/craigstoller/claude-code-sessions) moves Claude\nCode conversations between project folders and syncs their sidebar entries between\naccounts on the same machine. Copies are additive-only, strip account-specific state, and\nskip any thread the destination account has deleted — honouring tombstones the app writes\nbut doesn't. Every mutation is journaled, with `undo`\n\nand crash `recover`\n\n, and everything\nabove refuses first rather than guessing. It does **not** merge conversations, refresh\nstale copies (yet), or touch anything in the cloud — though what you do with a synced\nthread afterwards does: these safety claims are about local filesystem integrity, and\nwhether a particular conversation *should* cross an account boundary — work to personal,\nsay — is a data-governance question the tool cannot answer and will never refuse over.\nIt is unofficial, unaffiliated with Anthropic, verified on Windows only, and one\nstorage-format change away from needing new measurements. The\n[internals write-up](/craigstoller/claude-code-sessions/blob/main/docs/internals.md) carries the storage-model measurements and methodology\nbehind this post; the repo's history carries the rest, including the findings that didn't\nsurvive contact with a second data point.\n\n*Disclosure, concretely: this tool was built in Claude Code sessions. Claude wrote the\ncode and this post; I ran the parts that needed hands — sign-ins, account switches,\nclosing apps — and made the design calls the repo's decision records attribute to\nrulings. The session this post describes is one of the seven rows it synced.*", "url": "https://wpnews.pro/news/the-session-that-synced-itself", "canonical_source": "https://github.com/craigstoller/claude-code-sessions/blob/main/docs/the-session-that-synced-itself.md", "published_at": "2026-08-04 19:05:24+00:00", "updated_at": "2026-08-04 19:22:46.763606+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Claude Code", "Claude Desktop", "Anthropic", "claude-code-sessions"], "alternates": {"html": "https://wpnews.pro/news/the-session-that-synced-itself", "markdown": "https://wpnews.pro/news/the-session-that-synced-itself.md", "text": "https://wpnews.pro/news/the-session-that-synced-itself.txt", "jsonld": "https://wpnews.pro/news/the-session-that-synced-itself.jsonld"}}