{"slug": "show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts", "title": "Show HN: Keep all your Claude desktop Code sessions when you swap accounts", "summary": "A new open-source tool, claude-code-multi-account-bridge, merges the Claude desktop app's per-account Code tab session lists so users running two Claude accounts on one machine see the same session history regardless of which account is signed in. The bridge, written in Python 3.9+ standard library with no dependencies or install step, unions session records by session id across each account's <accountUuid>/<orgUuid> folder under claude-code-sessions, applies newest-copy-wins merging, and lets deletions and archive state carry across accounts via tombstones. Because the desktop app reads its folder only when an account loads, merged lists appear on the next account switch or app relaunch rather than mid-session.", "body_md": "One machine, two Claude accounts, one session list.\n\nThe Claude desktop app keeps its **Code** tab session list *per signed-in\naccount*. Each account gets its own folder:\n\n```\n<userData>/claude-code-sessions/<accountUuid>/<orgUuid>/\n    local_<sessionId>.json     one record per session (title, cwd, isArchived, ...)\n    deleted_<sessionId>        tombstone written when a session is deleted\n    scheduled-tasks.json       per-account — not ours\n    backlog/, spaces.json      per-account — not ours\n```\n\nThe transcripts those records point at are **already shared** across accounts\n(they live outside `userData`, keyed by session id), so a record copied into\nanother account's folder is fully resumable. Only the *list* is per account.\n\nSo if you alternate between two accounts — for example when one hits its weekly usage limit — the sidebar you see depends on which account you happen to be signed into. Work started on one account is invisible from the other, even though the conversation itself is right there on disk.\n\nThis tool merges those lists.\n\n**Symptoms this fixes:** the Code sidebar is empty or missing sessions after\nswitching Claude accounts; sessions disappeared when you signed into your second\naccount; you use two Claude accounts on one Mac (a second Max or Pro\nsubscription for when the first hits its weekly usage limit, a work Team seat\nand a personal plan) and each one only shows its own history.\n\n**Related tools.** Pick by what you want to happen at a switch:\n\n| Tool | What it does | Touches your sign-in | \n|---|---|---|\n| this bridge | keeps every account's list identical, all the time, in the background; deletions and archive state carry across; nothing to run when you switch | never | \n| [claude-transplant](https://github.com/vitaliyhayda/claude-transplant) | *moves* session records to the account you are switching to, with an undo, so one account lists each session at a time | no | \n| [claude-account-switcher](https://github.com/SnlperStripes/claude-account-switcher) | a tray app that switches the account for you without signing out, copies the list into the account it switches to, and shows live usage | yes: it saves and restores each account's sign-in | \n| side-by-side launchers (hats, multi-claude, twindesk, ...) | run one copy of the app per account, each with its own data folder, to keep accounts *apart* | no | \n\nThe launchers solve the opposite problem: they are for a work account and a\npersonal account that must never mix. This bridge is for two accounts doing the\n*same* work, where you want one list whichever account you are signed into.\n\n`sync` converges every `<account>/<org>` folder to the same set of records:\n\n1. **Discover** the leaf folders — exactly two levels under`claude-code-sessions` , both directory names canonical UUIDs, both real\ndirectories.\n2. **Union by session id** , newest copy wins (see the merge rules below).\n3. **Tombstones win** — a delete on one account is a delete everywhere.\n4. **Write** the winner into every folder that lacks it or holds a different\ncopy, and nothing else.\n5. **Report everything it could not act on** — a path it can neither read nor\nwrite, or a whole folder it cannot read or write, is counted, logged and left\nalone, so the plan converges instead of proposing the same impossible write on\nevery run.\n\nA missing or mistyped sessions root is an error, not \"nothing to do\": `sync` and\n`status` both name the path and exit non-zero rather than reporting an empty,\nin-sync tree.\n\nThe desktop app reads its folder when an account loads and does not watch it, so a merged list shows up on your next account switch or app relaunch — not mid-session.\n\nNo dependencies, no build, no install step. Python 3.9+ standard library only —\nwhich is exactly what macOS already ships at `/usr/bin/python3`.\n\n```\ngit clone <this repo>\ncd claude-code-multi-account-bridge\npython3 bridge.py status\npython3 bridge.py status            # what exists, and what a sync would change\npython3 bridge.py sync              # dry run (the default) — writes nothing\npython3 bridge.py sync --execute    # take a backup, then apply\npython3 bridge.py sync --execute -v # ... with debug logging\n```\n\nEvery run prints its counts — what the plan says should happen, and, when executing, what actually happened:\n\n```\nsync (execute): leaves=2 records_seen=137 copies_written=11 records_deleted=2 tombstones_copied=2 parse_failures=0 blocked=0 blocked_leaves=0 ignored=0 id_mismatches=0\nbackup: ~/.claude-code-multi-account-bridge/backups/20260101T120000000000Z skipped=0\napplied: copies_written=11 tombstones_copied=2 records_deleted=2 skipped=0 errors=0\n```\n\n`status` prints the same counts (as a `counts:` line) whether or not there is\nanything to do — a blocked path or an unreadable folder can sit in a tree that\nneeds no writes at all, and \"every leaf is in sync\" must not hide it.\n\nThe plan line (`sync (...)`) says what *should* happen; the `applied:` line says\nwhat *did*. Nothing the tool declines to do is ever invisible:\n\n| Count | Line | Meaning | \n|---|---|---|\n| `records_seen` | plan | distinct live session ids with at least one readable, parsable copy; tombstoned ids are not counted, and neither is an id whose only copies are blocked (two names differing only in case, say) | \n| `copies_written` /`tombstones_copied` /`records_deleted` | both | the work itself | \n| `parse_failures` | plan | records that are not a JSON object: never chosen as a winner, never deleted except by a tombstone | \n| `blocked` | plan | paths that can be neither read nor written — a directory or symlink wearing a record's name, an unreadable file, one over the 8 MB cap, or one of two names for the same id that differ only in case. No write and no delete is planned for them, so the plan still converges | \n| `blocked_leaves` | plan | whole account/org folders that cannot be listed, or cannot be written into (no write or execute permission). Nothing is written into them and nothing in them is deleted; the records they did yield still take part as sources | \n| `ignored` | plan | names that only look like ours ( `local_` /`deleted_` followed by something that is not a canonical UUID). Listed with`-v` | \n| `id_mismatches` | plan | the record's own `id` field disagrees with its filename. The filename wins (it is what the app reads the folder by) and the record is still synced | \n| `skipped` | applied | a write refused at the last moment because the destination changed between planning and writing, or a delete refused by a guard. The copy on disk is kept, the reason is logged, and the exit code stays 0 — nothing failed, and the next run converges | \n| `skipped` | `backup:` line | a DIFFERENT counter with the same name: paths deliberately left out of the backup because they could not be read (each one logged). Not related to the `applied` one above | \n| `errors` | applied | something failed outright — also makes the exit code non-zero. A copy that landed but whose mtime could not be restored counts in BOTH `copies_written` and`errors` : the bytes are there, but that copy now looks newer than every other and would win the next merge | \n\nExit codes:\n\n| Code | Meaning | \n|---|---|\n| 0 | done (or nothing to do) | \n| 1 | it started but did not finish cleanly — a write, a delete or the backup failed ( `errors` above) | \n| 2 | it could not start, or could not finish an agent command: bad `--user-data` /`--state-dir` , another run holds the lock, the state directory is not usable (not writable, or something that is not a regular file sits at the lock path), or the`LaunchAgents` directory cannot be written | \n| 3 | `install-agent` /`uninstall-agent` on a non-macOS platform | \n| 4 | `launchctl bootstrap` failed (the plist is removed again) | \n\nUseful flags:\n\n| Flag | Subcommands | Meaning | \n|---|---|---|\n| `--user-data DIR` | `sync` ,`status` ,`install-agent` | override the platform `userData` directory | \n| `--state-dir DIR` | `sync` ,`status` ,`install-agent` | override `~/.claude-code-multi-account-bridge` (backups + lock). Must be outside the sessions tree | \n| `--agents-dir DIR` | `status` ,`install-agent` ,`uninstall-agent` | override `~/Library/LaunchAgents` | \n| `-v` | all | debug logging. Counts go to stdout, every log line to stderr | \n\n`userData` is discovered per platform and can always be overridden:\n\n| Platform | Default | \n|---|---|\n| macOS | `~/Library/Application Support/Claude` | \n| Windows | `%APPDATA%\\Claude` | \n| Linux | `~/.config/Claude` | \n\n```\npython3 bridge.py install-agent     # write + load the LaunchAgent\npython3 bridge.py status            # ... says whether it is loaded\npython3 bridge.py uninstall-agent   # unload it and delete the plist\n```\n\n`install-agent` writes\n`~/Library/LaunchAgents/com.claude-code-multi-account-bridge.plist` with\n`WatchPaths` on the sessions root, **every account folder** and every current\nleaf folder, `ThrottleInterval 5` and a `StartInterval 60` floor (a run every\nminute even when nothing changed on disk — switching accounts inside the app\nwrites nothing, and a converged tree costs a run nothing), running\n`/usr/bin/python3 <repo>/bridge.py sync --execute --trim-logs`. All three levels are watched because launchd fires on a change\nto a watched folder's *own* contents: a new account shows up as a change to the\nroot, but a new **org** folder under an account that already exists only shows\nup as a change to that account folder. `sync` re-discovers leaves on every run\nregardless, and re-running `install-agent` refreshes the watch list. Logs land\nin `~/Library/Logs/com.claude-code-multi-account-bridge.{out,err}.log`.\n\nThe agent does **not** run the script from your checkout. launchd starts\n`/usr/bin/python3` without the privacy grants your terminal has, so a script\nunder `~/Documents`, `~/Desktop` or `~/Downloads` fails with `Operation not permitted` on every run (measured). `install-agent` therefore copies `bridge.py`\ninto `~/.claude-code-multi-account-bridge/` and points the job there. After you\npull a new version, run `install-agent` again to refresh that copy.\n\nlaunchd never rotates those two log files, so the agent trims them itself:\n`--trim-logs` cuts a log that has passed 5 MB back to its newest 512 KB, at a\nline boundary, at the start of an agent run. An agent installed by an earlier\nversion does not have the flag — run `install-agent` again to get it.\n\n`status` also reports the agent, and checks **both** halves: whether the plist\nis on disk and whether launchd actually has the job. A plist that is present but\nnot loaded does nothing while looking installed, and a job launchd holds with no\nplist on disk will not come back after a logout — that one reads `not loaded properly - launchd has the job but there is no plist at ...`, because it is a\nbroken install, not a working one. Away from macOS there is no launchd to ask, so\nthe answer is `unknown` rather than a verdict.\n\nFor each session id found in any leaf folder:\n\n1. **Newest `mtime` wins.** Archiving a session rewrites the file — bumping its\nmtime — without necessarily bumping`lastActivityAt` , so mtime is the\nprimary key. That is what makes \"archived on one account\" propagate to the\nother.\n2. **Tie on mtime → larger `lastActivityAt`.**\n3. **Still tied → larger SHA-256 of the file bytes.** Pure determinism: the\nsame tree always yields the same answer, whatever order the folders were\nread in.\n4. **A tombstone anywhere wins over everything.**`deleted_<id>` in any folder\nremoves`local_<id>.json` from*every* folder and is copied byte-for-byte\ninto every folder that lacks it. Session ids are UUIDs and are never reused,\nso a delete is final.\n5. **A record that does not parse as a JSON object is never chosen** — it is\nreported on stderr and counted in`parse_failures` . It is never deleted*except* by a tombstone: a tombstone is a decision about the session, not\nabout the bytes, so`deleted_<id>` removes an unparsable`local_<id>.json` like any other. If another folder holds a valid copy of that id, the valid\ncopy replaces it.\n6. **Case is identity, but the name on disk is the address.**`local_<UUID>.json` and`local_<uuid>.json` are the*same file* on a Mac's default filesystem and*two files* on a case-sensitive one, so ids are compared lower-cased while\nevery path is built from the name the folder actually listed. If one folder\nreally does hold two names for one id — only possible on a case-sensitive\nvolume — there is no way to know which one the app reads, so both are counted\nin`blocked` and neither is touched.\n7. **Converged means zero writes.** Winners are written with the winner's\nmtime, so a second run finds nothing to do.\n\n- **Dry run is the default.**`sync` alone only reports; writing needs`--execute` . A dry run takes no lock and creates no state directory — it is\nthe one command that still works while something else holds the lock, which\nis how you find out what is going on.\n- **A backup precedes the first write of a run.** Once a day the whole`claude-code-sessions` tree is copied to`~/.claude-code-multi-account-bridge/backups/<UTC timestamp>/` , keeping the\nnewest 10. Every other run that writes copies only the files it is about to\noverwrite or remove, to`backups/<UTC timestamp>-changes/` at the same\nrelative paths, keeping the newest 1000. Every copy keeps the file's bytes and\nexact mtime. (Earlier versions copied the whole tree before every writing run:\non the author's install that was 182 MB a time, hundreds of times a day, and\nthe ring of 10 covered only the last few minutes.) A`-changes` folder is the\nprevious copy of everything that run replaced or removed, so what the tool\noverwrites can be got back for as long as that folder is among the newest\n1000 (or a full copy still holds it); it does not list what the run added.\nBoth kinds are copied under a`.partial-` name and renamed when complete, so\na copy cut short by a logout or shutdown never passes for a backup. No backup is\ntaken for a dry run or when there is nothing to do; and a run that only adds\nfiles that did not exist before has nothing to save, so on a day that already\nhas its full copy it makes no backup folder. Anything this tool already\nreported as**unreadable** — a record, a whole account/org folder, or a\ndirectory wearing a record's name that cannot be read either — is left out of\nthe copy, named in the log and counted as`skipped` on the`backup:` line — otherwise a single\nunreadable record would fail the backup, and so the whole run, on every run for\never, which is the opposite of the \"report it and step around it\" rule those\npaths exist for.**Any other copy failure stops the run:** it removes the\nhalf-written backup, applies nothing and exits non-zero.\n- **A symlink in the backups folder is not a backup.** One named like a\ntimestamp is never chmod'd, never deleted and never counted against the\nnewest-10 ring. Neither is a folder that is not named exactly like one of this\ntool's timestamps: whatever else you keep in there is left alone.\n- **A lock file** (`~/.claude-code-multi-account-bridge/bridge.lock` ) stops\noverlapping`--execute` runs; a second run exits 2 and names the file and the\npid holding it. The pid is written into`bridge.lock.<pid>.tmp` first and the\nlock is claimed by hard-linking that file into place, so the lock is taken in\none atomic step and is never visible without its owner's pid inside it. A lock\nwhose owning process is**still alive is never stolen** . A lock left behind by\na killed run — its pid gone, or no pid ever written — is reclaimed once, with a\nwarning saying so, and only if it is still the very same file that was judged\nstale. On the way out a run removes**only the lock it claimed itself** , so it\ncan never delete a lock that belongs to somebody else. If something that is not\na regular file sits at that path, the state directory is reported as unusable\nrather than guessed about.\n- **Writes are atomic** — temp file in the*destination folder* (so the rename\ncannot cross filesystems), then`rename` , then the winner's mtime is restored\nand the folder is flushed. A reader never sees a half-written file, and an\nexisting destination keeps its permission bits.\n- **The destination is re-checked immediately before the rename.** If the app\nwrote a newer copy in the meantime, that copy is kept, the write is refused\nand counted in`skipped` . Overwriting it would also have stamped the older\ncopy's mtime on it, hiding the loss from every later run. To be honest about\nit: that check**narrows** the window, it cannot close it — there is no atomic\n\"replace only if unchanged\" here, so a copy written in the last sliver between\nthe check and the rename is still lost. The window is microseconds wide, and\nthe backup from the same run is the way back.\n- **Symlinks are never followed and never written through.** A symlinked\naccount or org folder is skipped during discovery; a record or tombstone path\nthat is a symlink is counted in`blocked` and no write or delete is ever\nplanned for it. Same for a directory wearing a record's name, and for a copy\nthat cannot be read — an unreadable copy additionally blocks*every* write of\nthat session id, since a copy we cannot read might be the newest one.\n- **A folder it cannot use is stepped around, not fought.** An account/org\nfolder with no read permission is never read from and never written into; one\nwith no write (or execute) permission is still read — it may hold the newest\ncopy of a session — but nothing is written into it and nothing in it is\ndeleted. Both are counted in`blocked_leaves` and named in the log on every\nrun. Without this the plan could never converge, which meant a full backup and\na fresh temp file on every single run.\n- **Nothing else in the session folders is touched.** Only`local_<uuid>.json` and`deleted_<uuid>` are read or written.`scheduled-tasks.json` ,`backlog/` ,`spaces.json` and any other file in those folders are left exactly as they\nwere — bytes and timestamps.\n- **What the tool removes, exhaustively.** In your session folders: a`local_<uuid>.json` superseded by a tombstone, and nothing else — the guard\nis re-checked at delete time, on the filename. Plus its own leftovers: stale`.bridge-tmp-*` files in**every** folder it read, not only the ones it is\nabout to write to (only regular files with that exact prefix, never a\ndirectory or symlink, and only on a`sync --execute` run, which is the only\none holding the lock — a dry run and`status` sweep nothing). That sweep runs\nbefore the backup and even when there is nothing else to do, because a\nconverged tree is exactly where a leftover would otherwise sit for ever.\nThen: full backups past the newest 10 and`-changes` folders past the newest\n1000 (never a symlink), a`.partial-<timestamp>` or`.partial-<timestamp>-changes` folder left in the backups folder by a run that\nwas killed mid-copy (never a symlink, and never a`.partial-` folder with any\nother name), the oldest part of its own two log files once one\npasses 5 MB, the lock file it claimed itself, and — on`uninstall-agent` —\nits plist.\n\n**`another run is already running (pid 1234, lock file ...); delete it if you are sure no run is in progress`** — exactly what it says, and the message names the\nfile. Two ways to get it:\n\n- \nA run really is in progress. Wait for it. `sync` (dry run) and`status` work\nregardless: they take no lock at all.\n- \n**A pid that has been reused.** A killed run leaves its lock behind; the lock\nis normally reclaimed automatically, because the pid inside it belongs to\nnothing any more. But pids get recycled, and if that number has since been\nhanded to some*unrelated* process, the lock looks alive and stays alive. There\nis no way to tell the two apart from the outside — a pid is all the lock has —\nso the tool refuses rather than stealing a lock that might be live. Check that\nno`bridge.py` is running (`ps ax | grep bridge.py` ) and delete the file the\nmessage named:\n\n```\nrm ~/.claude-code-multi-account-bridge/bridge.lock\n```\n\n**`cannot use the state directory ... exists and is not a regular file`** —\nsomething other than a lock file (a folder, a symlink) is sitting at\n`~/.claude-code-multi-account-bridge/bridge.lock`. The tool will not delete it:\nmove it out of the way yourself, or point `--state-dir` somewhere else.\n\n**`blocked_leaves=1` and a warning naming one of your account folders** — that\nfolder's permissions stop the tool reading it or writing into it. Everything else\nstill converges. `chmod u+rwx` on the folder if you want it included; otherwise\nit is left alone, run after run, and said so each time.\n\n**`blocked=N`, drift `none`** — there is nothing to do *and* something the tool\ndeclined to touch (a directory or symlink wearing a record's name, an unreadable\nfile, a record over the 8 MB cap). The counts line names the count on every run\nand each path is logged; nothing will be written to or deleted at those paths\nuntil you resolve them by hand.\n\n**`skipped=N` on the applied line, exit 0** — the desktop app wrote a newer copy\nof a record between the plan and the write, so that copy was kept and the write\nwas dropped. Nothing failed, and the next run (or the next `WatchPaths` trigger)\nconverges. The reason is logged for each one.\n\n**`not loaded properly - launchd has the job but there is no plist at ...`** —\nrun `python3 bridge.py install-agent` again; it rewrites the plist and reloads\nthe job.\n\nThe `sync` and `status` commands work anywhere a Claude `userData` directory\nexists (macOS, Windows, Linux). The automation is macOS-only for now, because it\nis a launchd LaunchAgent; `install-agent` refuses to run elsewhere. On Windows\nor Linux, call `bridge.py sync --execute` from Task Scheduler, a systemd timer,\nor cron.\n\n```\npython3 bridge.py uninstall-agent            # unload + delete the plist\nrm -rf ~/.claude-code-multi-account-bridge   # backups, lock file, the agent's copy of bridge.py\nrm -rf <this clone>\n```\n\nNothing else is installed anywhere. Records already merged into your account folders stay there — they are ordinary session records, and the app treats them as its own.\n\n- claude.ai chat history (server-side, and genuinely per account).\n- `scheduled-tasks.json` ,`backlog/` ,`spaces.json` , and every other\nper-account file.[Zakaria Mehbi's fork](https://github.com/zakariamehbi/claude-code-multi-account-bridge) goes further and also carries scheduled tasks, queued messages, session\npresets and the sidebar layout across accounts; that is not in this repo yet,\nbecause those files are rewritten whole by the running app and can only be\nproven with the app closed.\n- The `local-agent-mode-sessions` tree — same layout, may be added later.\n- Signing in or out for you, and anything else that would read or store a sign-in.\n- Live refresh of a running app.\n\nThe once-a-day full backup with per-run change sets, and the log trimming, follow\nthe design in [Zakaria Mehbi's fork](https://github.com/zakariamehbi/claude-code-multi-account-bridge).\n\n```\n/usr/bin/python3 -m unittest discover -s tests -v   # 3.9, as macOS ships it\npython3 -m unittest discover -s tests -v            # whatever is newer\n```\n\nThe merge is pure: `discover_leaves` → `read_leaf` → `plan_sync` → `Plan` →\n`apply_plan`. `plan_sync` touches no filesystem, so most of the suite asserts on\nplan objects directly. Tests build synthetic trees under a temp directory and\nnever read a real Claude directory; `launchctl` sits behind an injectable\nrunner and is never executed by a test.\n\nMIT — see [LICENSE](https://github.com/ramonmazinga/claude-code-multi-account-bridge/blob/main/LICENSE).", "url": "https://wpnews.pro/news/show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts", "canonical_source": "https://github.com/ramonmazinga/claude-code-multi-account-bridge", "published_at": "2026-11-12 08:41:12+00:00", "updated_at": "2026-11-12 08:52:44.039322+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["Claude", "Anthropic", "claude-code-multi-account-bridge", "claude-transplant", "claude-account-switcher"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts", "markdown": "https://wpnews.pro/news/show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts.md", "text": "https://wpnews.pro/news/show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts.txt", "jsonld": "https://wpnews.pro/news/show-hn-keep-all-your-claude-desktop-code-sessions-when-you-swap-accounts.jsonld"}}