Claude Session Mover · design notes

Claude Session Mover: design and implementation

Tue 13 Oct 2026 (week 42) · version 0.1.0 · code github.com/yaroslavvb/claude-session-mover (MIT, one Python file, standard library only) · live copy of this page spacesheep.dev/@yaroslavvb/claude-session-mover-design. Measurements are from Claude Code 2.1.296 on macOS.

One of your Claude accounts hits its usage limit while your other accounts have quota left. The usual answer is to start the work again in another account, and lose the conversation. Claude Session Mover moves the conversation instead: it copies the session's transcript into the other account's config home and resumes it there. The agent still knows everything that was said, and every turn from then on spends the new account's quota. Nothing is deleted from the old account.

Contents

Quick start

git clone https://github.com/yaroslavvb/claude-session-mover
cd claude-session-mover && ./install.sh          # links claude-session-mover and csm into ~/.local/bin

csm accounts                                      # the config homes found here, and who is signed in
csm list                                          # recent sessions across every account, newest first
csm move 3 --to work --dry-run                    # print the plan and any refusal, change nothing
csm move 3 --to work --stop-source                # move row 3 to the account named "work"

Requirements:

Each account needs its own config home: a directory you point CLAUDE_CONFIG_DIR at and sign in once (CLAUDE_CONFIG_DIR=~/.claude-work claude, then /login).

The idea in one picture

 account A (out of quota)                           account B (has quota)
 ~/.claude-a/                                       ~/.claude-b/
   projects/<folder>/<id>.jsonl   ── copy ──────▶     projects/<folder>/<id>.jsonl
   projects/<folder>/<id>/        ── copy ──────▶     projects/<folder>/<id>/
   .claude.json   (untouched)                         .claude.json  + trust <cwd>, onboarding done
   sessions/<pid>.json → live pid                     ▲
                                                      │ tmux window "moved-<id8>" in session "csm-b":
                                                      │ CLAUDE_CONFIG_DIR=~/.claude-b
                                                      │ claude --resume <id> --remote-control=<title>
                                                      ▼
                                       a NEW claude.ai session owned by account B:
                                       https://claude.ai/code/session_…

A Claude Code session on disk is one append-only JSONL file plus an optional sibling directory. The account is not part of what makes it resumable. It is whichever login the process runs under. So a move is a copy and a resume; everything else in the tool makes that copy safe and the resume unattended.

What a move does, step by step

  1. Find the session. Scan every account home's projects/*/*.jsonl and the live registry sessions/<pid>.json. Each file costs two seeks: a 96 KB head and a 384 KB tail, never a full parse. Pick the one you named (ladder below).
  2. Refuse early. Every check runs before the first byte is written, and each refusal names what to do instead. It refuses: - the same account as the source; - a target with no login, the wrong login, or the same login as the source; - a working directory that no longer exists; - a destination transcript that is live in the target; - an existing moved-<id> window.
  3. Copy. The transcript goes to <target home>/projects/<the source's own folder name>/, with its sibling <id>/ directory (subagents, tool results), and the project's memory/ if the target has none. A quiet file already at the destination is backed up first. The source is never edited or deleted.
  4. Pre-answer the first-run dialogs in the target's state file, additively, atomically, with a backup and the file's own mode: - the workspace-trust dialog for that directory (projects[<cwd>].hasTrustDialogAccepted); - onboarding (hasCompletedOnboarding, plus a theme if none is set).

A home that has never run interactively in that directory stops on both, and in a detached window nobody is there to answer. 5. Launch under the target login. A detached tmux window moved-<first 8 of id> in session csm-<account> runs the tool's own __run <plan.json>. That sets the target's CLAUDE_CONFIG_DIR, or unsets it for the default home. It scrubs the environment (below), restores PATH, and runs: claude --remote-control=<title, moved from A> --permission-mode <the session's own mode> --model <its model> --resume <id> in the session's recorded working directory. The hand-off goes through a file because a tmux window starts with the tmux server's environment, whatever that was when it started. The interpreter and the script are spelled absolutely. 6. Wait for proof. The tool watches the pane (capture-pane -J, so a wrapped link is whole) for https://claude.ai/code/session_…, up to 150 s. It answers a stray first-run prompt by its option number, and stops waiting as soon as the window prints its exit banner. 7. Optionally stop the source (--stop-source). This happens only after the link has appeared, only if the registry's pid is still a claude process, and never when the source is the session running the tool.

Two more modes:

Accounts: discovered, named, never numbered

An account here is a config home: the directory Claude Code keeps its login state, settings and transcripts in.

Anything ambiguous is refused with the candidates listed. A bare number is refused unless it is literally an account's name. A position in a list changes whenever an account is added or removed, so it cannot be trusted to mean the same account twice.

Picking a session

SESSION is tried against these, first unique win, and more than one match is listed, never guessed:

  1. a row number from the last csm list on this machine (saved, so move 3 means the row you just read; an all-digit argument is always a row number);
  2. an exact local session id, then a unique prefix of one (4+ characters);
  3. a claude.ai/code/session_… link, or a bare cse_… / session_… id;
  4. a unique, case-insensitive piece of the title.

A move is a copy, so after one move the same id exists in two accounts, with the same title. When an id or title matches several rows, the single live one wins; two live copies, or none, is a refusal that lists the row numbers.

What is on disk: the formats it reads

All of this is Claude Code's own undocumented on-disk state, read the way the CLI writes it in 2.1.x.

What Where Used for
Transcript <home>/projects/<folder>/<id>.jsonl the session itself; copied
Sibling dir <home>/projects/<folder>/<id>/ subagent transcripts, tool results; copied
Live registry <home>/sessions/<pid>.json: pid, sessionId, cwd, bridgeSessionId, tmux, name, status, entrypoint which sessions are running; the only map from a phone session to its transcript
State file <home>/.claude.json, or ~/.claude.json for the default home: oauthAccount, projects[cwd], hasCompletedOnboarding, theme who is signed in; the first-run dialogs

Transcript lines it reads, by type:

A session is running when its registry file names a pid that answers kill -0. Dead processes leave their files behind, so a file alone means nothing.

Measured facts the design rests on

An id that exists nowhere answered No conversation found. - The folder name is the launch path with every non-alphanumeric character turned into -: proj dir_x became proj-dir-x, both in a throwaway home and under a real login. A rule that replaces only / and . is wrong for paths with spaces or underscores. - The folder and the recorded cwd can disagree. A session started through a symlinked path can be filed under the symlink's spelling while it records the resolved path. In one real setup, 56 of 835 transcripts were like that. So the mover never recomputes the folder: it reuses the source's own folder name, and launches in the recorded cwd. - Two first-run dialogs stop a resumed session in a cold home: workspace trust, then onboarding (theme picker and security notice). Both were hit on the first real move, and the tool pre-answers both. - The trust dialog lists "Yes, I trust this folder" first. A fallback that presses Down and Enter would choose "No", so stray prompts are answered by the option's number. A second sighting one poll later gets Enter, and a third gets nothing. - "Enable Remote Control? (y/n)" prints Choose [1/2] (default: 1), so Enter accepts it. - The bypass-permissions warning offers "Yes, I accept" and "No, exit". - An error line names its model <synthetic>, and the out-of-quota line is that kind of line, at the end of exactly the session you want to move. claude --model '<synthetic>' refuses to start. So a model is taken only from a clean assistant line, and only if it looks like a model id. - A desktop-app transcript records its permission mode on every prompt line, not in a permission-mode line. A first prompt with a pasted screenshot can be bigger than the 96 KB head. If the tail has no mode either, the file is read backwards for the last one. A session that silently resumed in default mode would stop for a click on every tool call. - The new claude.ai thread starts at the move. The resumed session carries noHistoryBackfill. The agent has the whole conversation, but the app does not re-render the earlier turns above your next message.

Safety rules

Limits

From a phone

The repo ships a Claude Code skill, move-session. ./install.sh --skill links it into every account home, since each config home reads its own skills/. Then, in any session that still has quota (a Remote Control session on the machine, for example), you say "move my refactor session to work" or "I'm out of quota, move this session to personal". The agent lists the candidates, asks one question if it is ambiguous, runs the move, and answers with the account to open the app as and the new claude.ai/code/session_… link.

Why move rather than pool

The other way to use several subscriptions is a proxy in front of them that rotates logins by remaining quota. A pool answers as whichever login it picked, so nothing downstream can say which account paid for a turn, and it cannot move a conversation that is already running. A pool on an unattended machine is also a terms-of-service risk. The mover does something narrower: it hands a conversation to another of your own logins, and that login does its own talking, on its own token, in its own app. One session, one account, in the log.

Lessons from the first version

The mover was first built for one person's multi-account setup and reviewed in use. Each finding became a rule here:

Finding Rule now
--stop-source fired before the connect check, so a failed move left nothing running stop only after the link has appeared
a copied id matched in two accounts, and the first match was a stale copy the live copy wins; otherwise list and ask
the trust pre-answer and the copy ran before the last refusal preflight first: nothing is written before every check passes
the out-of-quota line's <synthetic> model was passed to --model a model only from a clean line, shaped like a model id
a desktop session moved as default: its mode was beyond the head window modes from prompt lines too, then a backwards read
--stop-source from inside the session killed the turn reporting the move recognise self, never stop it
--remote-control= names built from titles carried /, : and @ a cleaned label with the provenance kept whole
the folder name was recomputed from the cwd with a /-and-. rule reuse the source's folder name
a fallback answered the trust dialog with Down and Enter answer by the option's number
numbered accounts swapped numbers once accounts are named, never numbered

Repository and tests

Path What
claude-session-mover the tool: one Python file, standard library only
install.sh links claude-session-mover and csm into ~/.local/bin; --skill links the skill into every account home
skills/move-session/SKILL.md the Claude Code skill for the phone flow
tests/test_mover.py 49 tests, fixtures only
docs/design.md this page

The tests run the real script against fake homes and transcripts, a fake claude that records its argv and environment, and a fake tmux that really runs the moved window's command. They need no network and no account. They pass on Python 3.7, 3.9 and 3.12 on macOS, and CI runs them on Linux and macOS.

State lives in ~/.local/state/claude-session-mover/ ($XDG_STATE_HOME is honoured): log, one line per list, move, refusal and window start; picks.json, the numbering of the last list.


Why it matters: hitting a usage limit stops costing the conversation. The work keeps its memory and moves to an account with quota in under a minute, from a phone, with nobody at the keyboard.