skill
claude-sesh-mover
Claude Code Plugin for session import, export and migration between profiles, containers, and machines
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/Sertelegger/claude-sesh-mover itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 25, 2026claude-sesh-mover
Export, import, and migrate Claude Code sessions across machines, operating systems, config directories, and project paths — including incremental round-trips between two machines.
Claude Code sessions are JSONL files keyed to an absolute path on the machine that created them, so they can't follow you to another laptop, directory, or across the Windows ↔ WSL boundary. claude-sesh-mover rewrites the path-dependent bits so a session can be resumed anywhere.
| Command | What it does |
|---|---|
/sesh-mover:export | Bundle one or all sessions into a portable directory or .tar.gz/.tar.zst. Incremental mode sends only what's new since the last sync to a peer. |
/sesh-mover:import | Unpack on the target machine, rewrite paths, register for claude --resume. Idempotent — re-imports are skipped (--allow-duplicates overrides). |
/sesh-mover:migrate | Same-machine move: export + import + delete source. Optionally renames the project directory (--rename-dir). |
/sesh-mover:browse | List exports, view manifests, import, or delete. |
/sesh-mover:configure | Set defaults (scope, storage, format, layers) and the machine's name (--set machine.name=<label>). |
/sesh-mover:hub-init | Set up (or join) a shared directory as the cross-machine session hub. |
/sesh-mover:push | Publish this project's sessions to the hub. |
/sesh-mover:pull | Bring a project's thread down from the hub onto this machine. |
/sesh-mover:whereis | Read-only: which machines have which sessions for this project, and which is latest. |
/sesh-mover:hub-unlink | Unlink this directory from the hub: removes its .sesh-mover-project.json and nothing else, which disarms the automation here. Writes nothing to the hub. |
/sesh-mover:hub-retire | Retire a project from the hub: writes this machine's tombstone, which makes every machine refuse to pull it. Deletes nothing, and --undo takes it back. |
/sesh-mover:hub-delete | Delete a retired project's files from the hub, permanently. Only the machine that created it, and only 48h after retiring it. |
/sesh-mover:hub-compact | Consolidate a thread into one full bundle, then — on a later run, once every machine demonstrably has it — delete this machine's chain of continuation bundles behind it. The first run deletes nothing. |
/sesh-mover:hub-encrypt | Report the hub's encryption-at-rest setting, or --enable it so every machine's later pushes are encrypted bundles. |
/sesh-mover:hub-rekey | Re-address this machine's own encrypted bundles to every machine registered on the hub now — how a machine that joined later gets the history. |
/sesh-mover:hub-escrow | Write a passphrase-wrapped copy of this machine's own identity key to a path you name, so a rebuilt machine can get its key back. Off by default: the passphrase opens every bundle addressed to this machine, past and future, permanently. |
/sesh-mover:hub-trust | Show each machine's signing-key fingerprint and whether it is pinned on first use or confirmed; confirm one by typing in the fingerprint read off that machine, over a channel that is not the hub. Writes nothing to the hub. |
The hub also runs itself once a project is linked: a session-end auto-push and a session-start "newer work elsewhere" notice — see Automation for what they do, how to turn them off, and how to unlink a directory entirely. Where this is heading beyond that — a web service, and later other agentic CLIs — is tracked in ROADMAP.md. Release history: CHANGELOG.md.
Install
/plugin marketplace add sertelegger/claude-sesh-mover
/plugin install sesh-mover@claude-sesh-mover
If the slash commands don't appear, run /reload-plugins. No build step — the plugin only needs Node.js ≥ 22.12 (current Claude Code releases already require Node ≥ 22). Optional: a zstd binary for .tar.zst archives — exporting falls back to .tar.gz automatically without it, but reading a .tar.zst someone else sent you (browsing its metadata or importing it) does require zstd on this machine (brew install zstd, or sudo apt-get install -y zstd on Debian/Ubuntu/WSL).
For local development, clone and load directly (a pre-built dist/ ships in the repo):
git clone https://github.com/sertelegger/claude-sesh-mover.git
claude --plugin-dir ./claude-sesh-mover
Upgrading to 0.8.0 — a clean break
0.8.0 does not read, migrate, or warn about anything a previous version wrote. Two consecutive releases renamed on-disk paths, and 0.8.0 ends the compatibility machinery rather than carrying it: there is no fallback read of an old name, no rename-on-first-run, and no stale-name notice. These are the only paths it knows:
| What | Where it lives |
|---|---|
| Machine identity and keys, pinned peer keys, sync state, locks, user-scope exports and config | ~/.sesh-mover/ |
| Project-scope exports and parked carry payloads | <project>/.sesh-mover/ |
| The opt-in re-include list (committed) | <project>/.sesh-mover-include |
| The exclusion list (committed) | <project>/.sesh-mover-ignore |
| This project's hub identity (committed) | <project>/.sesh-mover-project.json |
The per-project state is split by lifecycle: generated exports (which must never be committed) live in .sesh-mover/, while config and identity (which must be committed, or they do nothing) are root dotfiles outside it. That is why a plain .sesh-mover/ line in .gitignore is all a project needs, with no negation.
Coming from an older version, here is the whole of it:
- Recreate
.sesh-mover-includeand.sesh-mover-ignoreat the project root, with the same contents as whatever you had before (.sesh-mover-hubinclude/.sesh-mover-hubignorein 0.7.0,hubinclude/hubignoreinside.claude-sesh-mover/before that). A file left under an old name is simply not read, so the project behaves as if it had no list at all — recoverable, and deliberately preferred to a list that is silently read in part. - For each project's hub link, either copy
.sesh-mover-project.jsonacross verbatim — from.claude-sesh-mover/project.jsonif that is where yours is — or do nothing, and let the next/sesh-mover:pushre-link the project by its git remote (or offer to mint a fresh hub project). Copying it across also re-arms the automation for that directory (Automation below), since the file's existence is the consent gate:sesh-mover hub unlinkremoves it again if that isn't what you wanted, and — unlike every other hub verb — it needs no configured or reachable hub to do it. ~/.claude-sesh-mover/and<project>/.claude-sesh-mover/can simply be deleted. Nothing reads them. A machine that had not already taken the 0.7.0 upgrade gets a new machine identity and starts its hub bookkeeping fresh; its sessions are still on the hub and still pullable.
A file left under a retired name is inert clutter, not a redirect — and that is also why the retired names are no longer on the never-syncable floor (below). A floor entry stops a payload planting a file some reader consults; with no reader left, it guarded nothing.
Quick start
Move a session from machine A to machine B:
- On A:
/sesh-mover:export→ pick "this session", archive format. Note the.tar.gzpath it reports. - Copy the archive to B (scp, cloud drive, whatever you trust — it contains your conversation).
- On B, in the project directory:
/sesh-mover:import→ point it at the archive, confirm the dry-run. claude --resume— the imported session is in the list.
Usage notes
Export prompts for scope (one session / all for the project), storage (~/.sesh-mover/ or ./.sesh-mover/), format, and layers (JSONL, file-history, tool-results, memory, plans, subagents). --no-summary keeps conversation excerpts out of the manifest and resume listings (the JSONL itself is still exported in full). A bundle carries sessions only unless you ask for the project's files as well — --include-workspace / --include-carry, both off by default (Project files in an export bundle).
Import shows a dry-run of the path rewrites before touching anything. If Claude Code rejects the session over a version mismatch, --no-register imports the content without the resume entry. Already-imported sessions are skipped and reported in skippedSessions; --allow-duplicates forces a re-import. Project files a bundle carries are applied only with --apply-workspace / --apply-carry, and by no config key at any time (same section).
Browse lists every export it finds in ~/.sesh-mover/, ./.sesh-mover/, and the project root (archives sitting loose in the project root are only picked up if their filename carries the standard YYYY-MM-DD- export prefix), with each bundle's origin platform, project path, export date, and session count — read from the bundle's own manifest, archives included, so a .tar.gz carried over from WSL still says wsl2. If a bundle's metadata can't be read (a .tar.zst on a machine without zstd, a corrupt archive), that entry is marked metadataAvailable: false with the reason instead of showing invented values, and the rest of the listing is unaffected. Reading .tar.zst metadata decompresses the whole bundle first, so browsing a directory of large zstd archives takes a moment.
Migrate is for same-machine moves (repo relocated, home dir renamed, config dir switched). Don't run it from inside the session being migrated — the CLI blocks this; exit, start a fresh session from an outer directory (e.g. ~/), and run it there. --scope current requires --session-id.
Exit codes, for scripting the CLI directly. A plugin install puts no sesh-mover on your PATH: the CLI is node <plugin dir>/dist/cli.js, where <plugin dir> is the directory Claude Code installed the plugin into (typically ~/.claude/plugins/cache/claude-sesh-mover/sesh-mover/<version>), and sesh-mover below stands for that. Every command emits one JSON object on stdout and exits with a code naming the class of outcome, so sesh-mover push || handle_failure works without parsing anything:
| code | meaning |
|---|---|
0 | success |
1 | the command did not run — a bad invocation, or an unexpected failure. Argument validation prints on stderr and leaves stdout empty; everything else still emits its JSON |
2 | refusal: understood and declined, nothing was done. "Already up to date", a project not linked to the hub, an unknown --project-id, and pull's "which thread?" listing |
3 | not ready: the invocation was fine and the machine is not — an unmounted share, a synced folder mid-copy, another sesh-mover operation holding this project's lock. This is the class worth retrying unchanged |
Two edges worth knowing. pull with neither --thread nor --latest answers with the list of threads and exits 2 — it pulled nothing, so a || branch should catch it, even though its body says success: true. And a zero exit is not by itself proof that work happened: a --dry-run, an export that hit a name collision, and a push with nothing new all exit 0 and say so in the JSON. The two internal hook endpoints (hub hook-session-end, hub hook-session-start) are outside this scheme and always exit 0; they are invoked by Claude Code, not by you.
export, import, migrate, push, and pull all accept --progress, which emits NDJSON progress events on stderr (one JSON object per line) while leaving stdout's JSON result contract unchanged — useful when driving the CLI directly rather than through the slash commands. On push and pull the hub-push/hub-pull events carry a coarse percent and the per-session detail comes from the export/import phases underneath them. A pull emits either no events at all (it was refused before it took the project lock) or an opening percent: 0 and a closing percent: 100 — the closing one always arrives, including when the pull is refused or fails, so 100 means "finished", not "succeeded".
Incremental sync between two machines
After one full export → import round-trip, exports to that peer can be incremental: new sessions ship whole, sessions with new messages ship as continuation sessions (a new session that starts with a lineage note, followed by only the messages added since the last sync), unchanged sessions are skipped. The export flow offers this automatically once a peer is known.
- Machine identity:
~/.sesh-mover/machine-id.json(name defaults to hostname). Per-project sync state:~/.sesh-mover/sync-state/. - No sync state for a peer?
/sesh-mover:export --incremental --since <path-to-previous-export>diffs against that bundle directly. - Work on one machine at a time: parallel edits to the same session produce two independent continuations, not a merge.
Project files in an export bundle
An export can carry the project's files as well as its sessions — the same two payloads push/pull carry, built and applied by the same code: a whole-tree snapshot for a project with no git remote, or a git diff HEAD patch plus untracked files for a project with one. Workspace snapshots and Uncommitted work below describe both in full, and every rule there about what is captured applies here unchanged; what is applied differs, and this section says how. Both ends are opt-in:
sesh-mover export --include-workspace # no git remote: a copy of the working tree
sesh-mover export --include-carry # git remote: the patch plus untracked files
sesh-mover export --payload-plan --include-carry # measure it, write nothing, export nothing
sesh-mover import --from <bundle> --apply-workspace
sesh-mover import --from <bundle> --apply-carry
Why the default is the opposite of push's. A hub bundle lands in a directory you configured and only your own machines read; that link is the consent gate, and it is given once. An export bundle has neither — --output names any path, and the artifact gets scp'd, emailed, dropped in a shared folder or handed to someone. Its destination is not known when it is built, so you choose rather than the tool. export.includeWorkspace and export.includeCarry raise the capture default per machine or per project if you always want it; the budgets are export.workspaceMaxMb and export.carryMaxMb, 50 MB each, kept separate from the hub.* pair because the hub's ride an unattended push on every session end and an export does not. There is no config key on the import side and there is not going to be one — capturing is a decision about your own outgoing bundles, applying is a per-invocation decision about someone else's incoming one.
The tracked-vs-untracked asymmetry bites harder here than on the hub. .gitignore decides which untracked files travel and nothing filters the patch: git diff HEAD describes every tracked file you changed, so a .env that was committed once and gitignored later without git rm --cached travels with its current value, in plaintext. That is the same rule the hub carry follows — what differs is the artifact. A hub bundle stays in a directory you control; this one is a file you hand to someone, and you cannot take it back. The export names that set back to you in carry.trackedIgnored and in a warning, and --payload-plan exists so you can read it before the bundle is written: it runs the same measuring pass the real export runs and reports the decision, the file count, the byte size, reIncluded, trackedIgnored and the gitignored paths it left behind — while writing nothing and exporting no sessions, so declining costs nothing.
The snapshot does not read .gitignore at all — it is a plain file copy, filtered only by .sesh-mover-ignore, the built-in excludes and the size budget — and that is exactly why it is taken only for a project git says has no remote. If git cannot be asked (no git binary, or a repository it refuses to answer about), neither payload is captured and the export says so: reading "could not ask" as "no remote" would take the whole-tree copy on a git project and ship the files .gitignore was keeping quiet.
Importing is a bootstrap, not a sync. --apply-workspace unpacks into the target project directory and that is all: no 3-way merge, no ancestor tracking, no --on-divergence. Those exist because two machines drift apart over repeated hub syncs; export→import is "move to a new machine". If you want merge semantics you want the hub. So a target directory that already has content is refused before anything is written — no sessions, no files — and the refusal names three ways on: point --target-project-path at an empty directory, drop --apply-workspace and take the sessions only, or pass --force-workspace. Plugin state does not count as content, so a directory holding nothing but .sesh-mover-project.json still counts as empty.
--force-workspace unpacks over what is there and replaces any file of the same name; your copy of that file does not survive anywhere. It is not a merge and never becomes one — anything the payload does not contain is left exactly where it is, and anything it does contain is written over. An import also records no workspace generation (a generation is identified by a hub bundle id, and an export mints none), so a later hub pull into that project has no ancestor to work from and falls back to keeping your files and showing conflicts.
Declining the carry on an import writes nothing at all — not even the saved .sesh-mover/carry-<timestamp>/ copy a pull parks. The difference is not an oversight. A pull has recorded its bundles as received and deleted its extraction directory by the time it decides, so re-running it never offers the same carry again and parking is the only remedy left; an export bundle is a file you still have, and sesh-mover import --from <same bundle> --apply-carry reaches the payload even when every session in it is a duplicate by then. Pass the flag and have a guard refuse — wrong commit, a dirty tree, a payload the floor rejects — and the payload is parked exactly as a pull's is.
Applying a payload from a bundle someone handed you is the first time this plugin writes arbitrary project files on a stranger's say-so. The flag is the whole of the consent. There is no path filter behind it and there is not going to be one: "names that subvert sesh-mover" is a finite list this plugin defines, while "names that lead to code execution" is a property of your toolchain. What is refused unconditionally is that finite list — .git, and everything sesh-mover owns (.sesh-mover/, .sesh-mover-include, .sesh-mover-ignore, .sesh-mover-project.json), at any depth and in any casing — plus anything that would create a symbolic link, and for a carry the guards below (a clean tree at the exact base commit, no merge or rebase in progress, git apply --check clean). Everything past that is an ordinary project file, and an ordinary project file is enough to reach code execution the next time you build or run something (#36). Treat --apply-workspace / --apply-carry on someone else's bundle the way you would treat running their build script.
The Hub
Incremental sync (above) moves sessions one peer-to-peer round-trip at a time — you still have to know which machine to pull from. The hub is a shared directory that removes that bookkeeping: push sessions to it from any machine, and any other machine that points at the same directory can see what's there and pull it down.
Quick start
- On machine A:
/sesh-mover:hub-init→ point it at a directory both machines can reach (see storage guidance below). This mints a hub (or joins one that already exists there). - Still on A, in a project directory:
/sesh-mover:push— publishes this project's sessions to the hub. First push in a project links it to a hub project (matched by git remote, or minted fresh). - On machine B:
/sesh-mover:hub-initpointed at the same directory, then in the same project (cloned there, or pulled fresh — see the bootstrap case in/sesh-mover:pull), run/sesh-mover:whereisto see every machine's copy of this project's sessions and which is latest, then/sesh-mover:pullto bring the one you want down onto B.
whereis is read-only — it never links a project or pulls anything, so it's safe to run just to look.
Automation
Once a project is linked to a hub project, sesh-mover keeps it current without being asked. Two Claude Code lifecycle hooks do it:
- Session end → push. When a Claude Code session ends in a linked project, the same payload a manual push would send goes by itself: this project's sessions, plus a workspace snapshot (no git remote) or a
git diff HEADpatch and untracked files (git remote). What it does not do is talk to you. It runs detached, so it never delays session exit, and its output goes nowhere a clean exit shows — so the warnings a manual push prints (including the disclosure naming gitignored-but-tracked files whose contents the patch carried off this machine) are not printed here, and a chronically failing auto-push (an unmounted share, say) doesn't announce itself either. Both are recorded instead:sesh-mover hub statusreports the last automatic push for the project you're in, with its warnings or its error (lastAutoPush). Detached also means nothing bounds it: a push wedged on an unreachable network path leaves anodeprocess running until the path answers. - Session start → notice. When a session starts or resumes in a linked project, sesh-mover checks the hub and, if another machine holds newer work for this project, injects one line naming the thread, the machine, and how long ago — then gets out of the way. It's bounded at 10 seconds so an unreachable hub can't stall session start, and it says nothing at all when this machine already has the latest of everything.
Turning off Claude Code mods with disableAllHooks turns these off too. Claude Code's mods documentation points at disableAllHooks: true as the switch for every installed mod, and that setting also stops plugin lifecycle hooks — so the session-end push stops silently, visible only in hub status's lastAutoPush. To turn off a single mod, disable that mod's plugin instead.
Linking a project is the consent gate. Both are on by default and both are inert until a hub is configured and the project is linked — so nothing happens for anyone who never uses the hub. The flip side: if you linked a project under an earlier version, the automatic push starts at your next session end, with no further action. Turn either off per machine (or add --scope project for one project):
sesh-mover configure --set hub.autoPush=false
sesh-mover configure --set hub.startupNotice=false
The automatic push takes no flags, so --no-workspace / --no-carry can't be given to it — hub.noWorkspace=true and hub.carryDiff=false are how you opt out of those payloads everywhere, manual pushes included.
Unlinking is the other lever, and it works on one directory at a time:
sesh-mover hub unlink [--project-path <dir>] # or /sesh-mover:hub-unlink
Because the link is the consent gate, removing <project>/.sesh-mover-project.json makes both hooks inert for that directory — and that file is all hub unlink removes. It writes nothing to the hub (it doesn't even open it), so sessions already pushed stay there and no other machine is affected, and it deliberately does not require a configured or reachable hub: this is the disarm path for automation that is on by default, so an unmounted share or a hub.path you have since removed must not be able to block it. It is idempotent (an unlinked directory reports wasLinked: false and succeeds), it takes the same per-project lock push and pull take (--force skips that, at the price of a still-running push re-creating the link when it finishes), and it keeps this machine's sync bookkeeping on purpose — the result hands you back the projectId, so sesh-mover push --project-id <id> re-links to the same hub project and carries on rather than re-uploading everything. Deleting that one root dotfile by hand does the same thing; the command adds the lock, the disclosure and the id.
Retiring a project is the hub-side counterpart, and it is deliberately two steps:
sesh-mover hub retire [--reason "<why>"] # or /sesh-mover:hub-retire
sesh-mover hub delete # 48h later — or /sesh-mover:hub-delete
hub retire writes one small file — projects/<id>/tombstones/<machineId>.json, this machine's own, so no machine ever writes another's — and removes nothing. Its effect is that a pull of that project is refused, with a message naming who retired it, why, and what the reader can still do; hub retire --undo withdraws it, from the asserting machine only. hub delete is the irreversible half: it removes every file of the project from the hub, and it refuses unless this machine created the project and its tombstone is more than 48 hours old.
That wait is the point of the design rather than caution for its own sake. A tombstone is a new-start gate: it cannot interrupt a pull that is already running, and on a synced hub it cannot fire on a machine the file has not reached yet — a laptop closed for the weekend learns nothing until it opens. So the safety comes from the two phases plus enough time for the assertion to propagate, and the constant errs long on purpose: too short, and a machine that never saw the retirement pulls bundles that are being deleted underneath it. If you need work off the hub before that deadline, sesh-mover pull --ignore-retirement fetches it anyway. Retirement does not block pushes — a machine still linked keeps uploading, including through the session-end auto-push — so hub unlink on each machine is what actually stops that.
Storage
The hub backend is filesystem-only in this slice: point hub init --path at anything that behaves like a directory both machines can read and write — as an absolute path (a leading ~ is expanded to your home directory; a relative path is refused, as it is for configure --set hub.path) —
- a network share (SMB/NFS mount), or
- a synced folder (OneDrive, Dropbox, Syncthing, iCloud Drive, …).
Synced folders have three gotchas the hub can't fully paper over:
- On-demand hydration (OneDrive Files On-Demand, Dropbox Smart Sync, and similar): a file can exist in the folder's listing before its content is actually downloaded to this machine. If
pullneeds a bundle that hasn't hydrated yet, it returnsreason: "not-yet-synced"with the specific files it's waiting on, instead of silently reading a truncated file. - Sync lag: a push from another machine may not be visible here until the sync client catches up. The fix in both cases is the same — wait a moment for the client to sync/hydrate, then retry the same command. On
not-yet-synced, the slash command will offer to retry for you rather than retrying automatically. - Joining before the first sync lands:
hub initon a folder that already holds other machines'machines/orprojects/but nohub.jsonyet refuses withreason: "hub-content-without-identity"(exit 3) and writes nothing, instead of minting a second hub identity over the first — which, once the twohub.jsonfiles met in the sync, broke every signature and pin and could unseal an encrypted hub. Wait for the sync to finish and run the samehub initagain.--accept-new-hub-idmints a new identity anyway, for ahub.jsonthat is genuinely lost. Likewise,hub initwhere this machine already joined a hub — or, with no record of joining one there, where its own projects use that path for a hub, or might — and now finds nothing (an empty directory, or none at all: an unmounted share's mount point) refuses withreason: "hub-identity-not-present"(exit 3) without creating the directory; mount the share and run it again. "Might" is a project whose configuration cannot be read: its directory is not there (a project on the same unmounted share, or one that was deleted and left its sync-state behind), or its config is unreadable or unusable. The refusal names each such project inunresolvedProjects, and it applies on a machine that has only ever used one hub too — mount or fix what it names, or pass--accept-new-hub-idif you really are starting a new hub there.
Trust model
The hub directory is a trust boundary, not a vault. Sessions are stored there in plaintext at rest unless you turn encryption on — see Encryption at rest below — so by default anyone with read access to the hub folder can read every session ever pushed to it. Every pulled bundle still goes through the same tar-entry and manifest-id validation as a manual import — see Security notes below — and one that carries a signature is checked against it before it is unpacked (Bundle signing below); the hub doesn't relax any of that, it just adds a shared drop point on top.
Point it at a directory only your own machines can write. Pulling is not a read-only operation: a bundle can carry project files (a workspace snapshot, or another machine's uncommitted work), and applying them writes into your working tree. A short list of names is refused unconditionally, on every path — .git, and everything sesh-mover owns (.sesh-mover/, .sesh-mover-include, .sesh-mover-ignore, .sesh-mover-project.json), at any depth and in any casing or dot spelling, so a bundle can never plant the files that decide where your hub is or what your next push uploads — and a carry needs --apply-carry plus a clean tree at the exact base commit before it touches anything. Beyond those, an applied payload can write ordinary project files, which on a shared or hostile hub would be enough to reach code execution the next time you build or run something (#36). Relatedly, the merge's "only merge against a generation both trees held" rule is verified on your side and taken on trust from the sender's (#37). Both are fine for a folder shared between machines you own, which is what this slice is for; both are gates on any future multi-user backend.
Signing changes who you are trusting, not whether you are. Since 0.11.0 a push signs a statement about the bundle it wrote, and a pull refuses a signed bundle whose signature, location or content does not match — so a bundle can be attributed to the machine that pushed it. That attribution rests on a key this machine pinned the first time it saw the peer sign — kept under the hub identity this machine joined — and on nothing the hub says after that: on a shared folder, whoever can rewrite a bundle can rewrite the peer's published key too, so a signature checked against the hub's copy of the key detects only a tamperer who declines to re-sign. First use trusts the hub once per machine, and joining trusts the hub's word for its own identity once; hub trust, which compares a fingerprint out of band, is the only step that does not take the hub's word for a key. And a signature says which machine wrote a bundle, never that its contents are safe — a compromised machine signs hostile content perfectly — so nothing above about applied payloads is relaxed by it. Bundle signing has the full account, including what goes unchecked on an unsigned bundle.
The same reasoning applies harder to an export bundle, because a hub is at least a directory you chose and a bundle is a file anyone can hand you: import --apply-workspace / --apply-carry is the one place sesh-mover writes project files from a source you have no relationship with. Both are off by default and neither has a config key — see Project files in an export bundle.
Encryption at rest
Off by default. sesh-mover hub encrypt --enable (or /sesh-mover:hub-encrypt) turns it on for the hub, not for one machine, and from then on every machine's pushes upload an encrypted bundle — <stamp>-<id>.tar.gz.age instead of <stamp>-<id>.tar.gz.
Enabling encryption does not make an existing hub private. It makes it private going forward. Every bundle already on the hub stays readable by anyone with read access, forever. Nothing rewrites them: one machine rewriting other machines' files is exactly what the hub's per-machine ownership rule forbids, and that rule is what makes concurrent push and pull safe without a distributed lock. If the existing bundles matter, start a fresh hub.
So a hub is permanently mixed, and that is the design rather than a transitional state. Readers decide per file, from the suffix the index recorded — never from local config — so flipping the switch never strands your own history, and a machine that pulls a thread spanning the switch reads the plaintext half and the encrypted half in one go.
Keys. Each machine mints an X25519 identity at ~/.sesh-mover/identity.age (mode 0600) on first use — this is the encryption key; the signing key is a second, separate file, covered under Bundle signing. The private half is never transported; the public half is published in that machine's hub record on every push and pull, which is what makes the recipient list derivable from the hub itself. Joining a machine is hub init there — no key exchange, no flag day. A bundle is encrypted to every machine registered on the hub at the moment it is written, which has one consequence worth knowing up front: a machine that joins later gets new bundles immediately and does not retroactively gain access to old ones. sesh-mover hub rekey (or /sesh-mover:hub-rekey) is the remedy, and it is per machine over its own bundles — see below.
The format is age v1, implemented in-process — no external binary, nothing to be missing, nothing that can prompt on an unattended session-end push. The point of using a standard format is the escape hatch: age -d -i ~/.sesh-mover/identity.age <bundle>.tar.gz.age recovers a bundle with this plugin uninstalled, so losing the plugin and losing your sessions are not the same event.
What it does and does not buy. An authentication tag proves the payload was produced by someone holding the file key — and under a shared-recipient design that is every participating machine. It authenticates the group, not the sender. What is genuinely gained: a hub operator who is not one of your machines can no longer read a bundle or author one at all, which is the last remedy #36 lists. What is not gained: anything against a peer machine, which is a recipient by construction, and no attestation of what a bundle descends from (#37). Naming the sender is what bundle signing is for, and it is a separate mechanism with its own limits: it is computed over the plaintext archive, so the two compose without either knowing the other exists, and a signed bundle is exactly as trustworthy as the machine that signed it.
Refusals, and why there is no plaintext fallback. Confidentiality has no lesser version to degrade to — unlike the zstd→gzip and git merge-file→no-ancestor fallbacks elsewhere in this project, whose weaker mode still satisfies the same property, the only thing a failed encryptor could fall back to is plaintext, and that is invisible to everyone except the machine that pushed. So:
hub encrypt --enablerefuses while any registered machine last checked in on a plugin version older than 0.10.0, and names it. That machine does not read the hub's setting at all, so sealing the hub would not stop it uploading your sessions in the clear — it would only stop you noticing. Upgrade it, then run a plainhub encryptthere once (/sesh-mover:hub-encrypt, which only reports) or push or pull any project on this hub from it — those are what refresh a machine's record, whilehub status,whereisandhub trustdo not touch it — or delete itsmachines/<id>.jsonif it is decommissioned.pushrefuses when a registered machine publishes no usable public key, naming each one. A push writes its bundle once, addressed to the roster at that moment, so a machine left out of it reads nothing of that thread until the pushing machine re-addresses its own bundles withhub rekey— and never, if that machine is gone by then. It would find out much later, on the other machine, as an unreadable bundle.push --force-unkeyeduploads anyway and records which machines were excluded; there is no config key for it, so the unattended session-end auto-push can never make that choice for you. It does not override the case where the pushing machine is itself the un-keyed one, because "those machines don't need this" cannot be true of the machine writing the file.pulltells four failures apart, because their remedies share nothing: this machine has no usable key (fix~/.sesh-mover/identity.age); this machine is not a recipient of this bundle (the machine that wrote it runshub rekey, or re-sends withpush --full); the bundle failed authentication (damage or tampering — retry, or ask for a re-push); or the file simply could not be read (retry). Nothing from a refused bundle is applied, and bundles applied earlier in the same chain stay applied and recorded.
Rekeying: who can do it, and the residual. hub rekey unwraps each bundle's file key with this machine's identity and re-wraps it for the hub's current roster; the payload ciphertext is copied untouched, so it is cheap, and the file keeps its name, so no index has to change. It is per machine, over its own bundles only — machine A re-wraps A's bundles and nobody else's, which is the same per-machine ownership rule that makes concurrent push and pull safe. Two consequences follow and neither is worked around: a decommissioned machine can never re-wrap its bundles, so its history stays unreadable to anyone who joined after it left; and a machine that lost or replaced its key cannot re-wrap its own past bundles either, because a re-wrap has to open them first. A rekey also grants access and can never revoke it — the file key does not change, so a machine that already read a bundle still can.
The hub is not a backup, and encryption makes that loud instead of quiet. Lose every key and every encrypted bundle on the hub is permanently unreadable. The sessions mostly survive anyway, because a push copies and never deletes the source — what does not survive is anything the hub is the only copy of. Recovery from a lost key needs push --full from a machine that still has the sessions: without it the next push ships a delta anchored on a base nobody can read.
Key escrow (/sesh-mover:hub-escrow) is the answer to that paragraph, and it is off by default. hub escrow --enable --passphrase-stdin --out <path>, run from your own shell with the line below, writes a passphrase-wrapped copy of this machine's own ~/.sesh-mover/identity.age to a path you name — 0600, created with wx so nothing is ever overwritten, and read back through the real reader and compared before the command reports success (if it does not read back it is deleted and the command fails, which is the one failure worth catching now rather than during a recovery as "my passphrase doesn't work"). Recovery is standard age and needs nothing from this plugin: age -d -i <escrow> <bundle>.tar.gz.age reads a bundle directly, because age accepts a passphrase-encrypted file as an identity file, and age -d <escrow> > ~/.sesh-mover/identity.age && chmod 600 ~/.sesh-mover/identity.age puts the machine's identity back. It is for RECOVERY ONLY. Restoring it on a rebuilt machine restores that machine's own identity, so no key moves anywhere. Using it to hand a different machine access does move one, and it collapses two machines into a single identity: from then on revoking either revokes both, and per-machine revocation is the reason the identities are per-machine at all. For backfill — a machine that joined later and cannot read the history — the correct tool is hub rekey, which does it without moving a key.
The passphrase unwraps this machine's identity, which unwraps every hub bundle addressed to this machine, past and future, for anyone who learns it. A leak cannot be revoked.
hub rekeyre-addresses bundles to a new roster but never changes a file key, so everything a leaked key could already read stays readable by it permanently — there is no command in this plugin, and none in age, that takes that back. This is also the one thing encryption at rest gives up: the design's no private key is ever transported property, traded for a key that survives the machine.
The passphrase comes from stdin, once, and from nowhere else — not a flag (it lands in shell history), not a config key (plaintext beside the thing it protects), and not an environment variable (it leaks through /proc/<pid>/environ and is inherited by every subprocess, including the git this plugin runs on your project, since gitChildEnv() scrubs GIT_* and would pass anything else straight through to git, its credential helper and its hooks). Type it in your own shell, never into a Claude Code session — a passphrase typed in chat is written to the session JSONL that the default-on session-end auto-push then uploads, encrypted to a key that very passphrase unwraps:
( IFS= read -rs SESH_ESCROW && printf '%s' "$SESH_ESCROW" | node "<plugin dir>/dist/cli.js" hub escrow --enable --passphrase-stdin --out <path> )
& { $p = Read-Host -Prompt 'Escrow passphrase' -AsSecureString; $OutputEncoding = [System.Text.UTF8Encoding]::new($false); [System.Net.NetworkCredential]::new('', $p).Password | node "<plugin dir>/dist/cli.js" hub escrow --enable --passphrase-stdin --out <path> }
/sesh-mover:hub-escrow prints both with <plugin dir> already filled in — the directory Claude Code installed this plugin into, typically ~/.claude/plugins/cache/claude-sesh-mover/sesh-mover/<version> — and a passphrase refusal from the CLI prints them with the path of the cli.js that ran. A plugin install puts no sesh-mover on your PATH, which is why neither line uses one. The first needs bash or zsh, since dash and fish reject read -s — on Windows that means Git Bash, or WSL only if Claude Code itself runs inside WSL, because a native-Windows install's path is a C:\… one that WSL's own node cannot open; its IFS= and -r keep read from stripping edge spaces and backslashes, without which the escrow would open only for a passphrase you never typed, and its parentheses keep the passphrase from staying behind in a shell variable once the line finishes. The second is PowerShell, and the one to use on native Windows; setting $OutputEncoding is what stops Windows PowerShell 5.1 sending a non-ASCII passphrase as ?.
Exactly one trailing newline is stripped, so printf '%s', echo and PowerShell's CRLF all produce the same key and match what age does with what you type at its own prompt. A leading UTF-8 byte-order mark is dropped too: Windows PowerShell 5.1 can prepend one when it pipes to a native command, and left in, the escrow would open only with an invisible character nobody can type. A newline in the middle is refused (it could never be typed back at that prompt), an empty passphrase is refused, and a stdin that is a terminal is refused rather than read, because reading would echo it into your scrollback.
Where it may be written, and what that check cannot see. --out is refused when the containing directory does not exist (it is not created for you — a typo would silently make one), when a file is already there, or when the destination sits inside the configured hub directory, inside a sesh-mover project (or the project the command ran for), inside a git work tree, or under a directory that looks like a cloud-sync folder by name or by marker file. Each is a way the escrow ends up somewhere that copies it: a project tree is what push uploads, one git add -A puts it on a remote forever, and a synced folder hands it to a third party who can then grind the passphrase offline. The parent directory is realpath'd first, so a symlink pointing into a repository cannot slip past lexically. There is no override flag — work around a false positive by writing elsewhere and moving the file yourself, which keeps the refusal meaningful. The limit, stated rather than implied away: this matches directory names, marker files, git work trees, sesh-mover projects and the hub directory, and nothing else. It cannot tell that your home directory — or any parent of it — is itself a synced folder, a network mount, a backup target or a shared volume. It is a guard against the obvious mistake, not a guarantee, and it ships in a limits array on every result, refusals and successes alike, precisely because a refusal that fires makes the check look more capable than it is. Writing into ~/.sesh-mover is a warning rather than a refusal: not a leak, but not recovery either, since anything that loses the identity file loses the escrow beside it.
The rest of it, in numbers and in what did not change. The work factor is fixed at scrypt logN 18 (N = 262144, r = 8, p = 1) — age 1.2.1's own constant, so the file is indistinguishable from one age -e -p wrote. It is deliberately not configurable: there is no minimum work factor in the format (logN 1 decrypts cleanly under age, measured), so a silently weak escrow is accepted by every tool and the writer's choice is the only protection there is. The read side caps logN at 22, matching age, because 128 · 2^logN · r bytes are allocated to derive the key and a file claiming logN 30 is a 1 TiB allocation request. The read-back check proves the file on disk opens; it cannot catch a defect symmetric in this plugin's own encryptor and decryptor, which is what the differential tests against the real age binary are for. And nothing on the bundle path changed: push, pull, rekey, retire and reindex do not know this file exists, and no bundle is ever addressed to a passphrase — the age spec forbids mixing an scrypt stanza with X25519 ones, and both sides of the implementation enforce "exactly one stanza" rather than tolerating a mix. hub escrow --disable forgets the record and deliberately does not delete the escrow file: the path was recorded once and the file there now may not be the one we wrote. It reports the path, and that file still unwraps this machine's identity for anyone with the passphrase — deleting it is yours to do.
hub.encrypt in your config is a local preference, never the switch. The authoritative one is the hub's own hub.json, so a machine that never set the preference still refuses to push plaintext into a sealed hub — the failure a local-only flag has is one machine quietly uploading in the clear to a hub you believe is sealed.
Bundle signing
Since 0.11.0 every push signs a statement about the bundle it wrote — the hub, project, machine, bundle id and file path it belongs at, and a sha256 of the plaintext archive — with an Ed25519 key minted at ~/.sesh-mover/signing.key on the machine's next hub operation. The signature rides that machine's own index record rather than the bundle, and the public key is published in its machines/<id>.json beside the encryption recipient. A pull checks a signed bundle before unpacking it, so nothing parses transcript bytes that failed attribution, and it tells four failures apart because their remedies share nothing: the signature does not verify (damage or tampering, and a half-synced index file is by far the likelier — retry); it verifies for a different location (a valid statement lifted off one bundle and re-filed on another; damage does not move a valid signature between records, so this is the strongest evidence of deliberate tampering the check can produce); the archive is not the one the statement describes (it changed after it was signed); or it is signed with a key that is not the one pinned here (below). Nothing from a refused bundle is applied, and the bundles applied before it in the chain stay applied.
What a signature proves, stated before the mechanism. It proves which machine wrote a bundle. It says nothing about whether the contents are safe: a valid signature from a machine that has been compromised authenticates hostile content perfectly, so --apply-carry, --force-workspace and every other consent gate around applying project files stay exactly where they are. Nor does it attest what a bundle descends from — the merge-ancestor claim under the trust model is now a self-report from a machine you can name, which is not the same as a verified one (#37). And it is computed over the plaintext archive, so it survives a hub rekey, which replaces the age header and touches nothing the signature covers.
The pin is what carries the property, not the signature. On a shared-folder hub, anyone who can rewrite a bundle can also rewrite that machine's machines/<id>.json and publish a key of their own — so a signature checked against the key the hub publishes detects exactly one adversary: a tamperer who declines to re-sign. What makes it mean more is that this machine pins each peer's key locally, the first time a signed bundle from that peer verifies, and from then on a different key refuses the pull. Pins live in ~/.sesh-mover/key-pins.json, per hub and per machine, and never on the hub — a pin stored beside the key it vouches for is circular.
"Per hub" means the hub identity this machine joined, not whatever the hub says today. Each hub records its identity in its own hub.json, which anyone who can write the hub can edit — and until this was checked, rewriting that one field made every pin on every receiver stop applying, so a substituted key was pinned as a first sighting (a confirmed pin included). Now each machine remembers, per hub path, the identity it joined, in ~/.sesh-mover/joined-hubs.json — written by hub init, or on a machine upgraded from an older version, recorded on its first hub command other than hub status or whereis (which never record) — and only when the machine's own records agree with the hub unambiguously: they name no hub at all, or exactly one, and it is the one hub.json names. The records that count are the ones tied to that hub path (the projects on the machine that use it) where there are any, and otherwise — or when not every project's configuration could be read, since an unreadable project might be the one using the path — every hub id the machine's sync-state and pins name. Anything else is refused rather than recorded — including a hub whose id the records name beside another, which is what a project that moved between hubs leaves behind — and an explicit hub init --path <dir> is then how you tell the machine which hub the path is. What is left: if every record this machine holds for a path names a single hub id that the path no longer serves, a hub writer who sets hub.json to exactly that stale id before this machine has recorded an identity for the path gets it seeded. That requires a project to have moved between hubs, or the hub at its path to have been re-created (a project's sync-state keeps the hub id it was first stamped with either way), and it can only happen before the first recording command there — in that state the honest hub is refused too, so an explicit hub init --path <dir> closes it. Any ambiguity refuses. Once an identity is recorded, a hub.json whose identity changes makes every hub command that reads a pin or writes the hub refuse with reason: "hub-identity-changed" until you deliberately re-join (hub status and whereis report it instead). That can mean you re-created the hub or pointed hub.path at a different one — or that someone rewrote hub.json. If you did not do it yourself, stop and find out who can write that directory. If you did, hub init --path <dir> --accept-new-hub-id records the new identity and carries this machine's pins forward to it (confirmed stays confirmed), so a key that differs from its pin still refuses and bundles signed under the old identity still verify here. (Pass the same --scope your hub.path is set in — the refusal names it.) When the machine had no record for that path yet, the refusal's remedy is a plain hub init --path <dir> instead, and that one carries no pin over from another identity: every machine on that hub not already pinned under its identity is pinned afresh on first use. --accept-new-hub-id is a flag only — no config key, and neither session hook can pass it. The first join is the one moment this trusts the hub's word for its identity, exactly as a first pin trusts a key. That is trust-on-first-use: it trusts the hub's word once per machine, which covers the realistic timeline (hub honest when the fleet was set up, compromised or migrated later) and covers nothing about an attacker already in place before this machine first pulled.
hub trust is the only step that does not take the hub's word for a key. sesh-mover hub trust (or /sesh-mover:hub-trust) lists every machine's fingerprint and whether its pin is tofu or confirmed; hub trust --machine <id> --fingerprint <fp> records a pin as confirmed — after you have read the fingerprint off that machine itself, over a channel that is not the hub. It refuses a fingerprint that does not match what the hub is publishing, on purpose: confirming whatever the hub says would be first-use trust with extra steps and a false sense of having checked. A confirmed pin is never silently replaced, and a changed key is not reported as an attack — a machine that lost its key and re-minted looks identical from here, which is why nothing resolves it automatically and the remedy is a human and hub trust. It is optional: a single-owner fleet may reasonably skip the ceremony, and the report says which machines are unconfirmed rather than nagging.
Unsigned bundles apply, permanently. Every bundle pushed before 0.11.0 carries no signature, and so does one from a machine whose signing key was unreadable at push time — that push warns and uploads unsigned rather than failing, by owner ruling. Both apply exactly as before, and there is no "require signed" mode (#107, deferred). What stops absence being a tamperer's free pass is the downgrade warning: once this machine holds a pin for a peer, an unsigned bundle from it is called out — a warning, not a refusal, because a machine that legitimately stopped signing looks the same as a tamperer stripping the field, and refusing would strand it. hub reindex re-signs the records it rebuilds, since the one machine allowed to write an index holds the key; a record it cannot re-sign is left unsigned rather than carrying a digest nobody can reproduce. That re-signature is over whatever is on the hub now, and it cannot be otherwise: the digest a push signs is stored inside the signature, which lived in the index the command is rebuilding, so nothing local survives to corroborate the bytes against. A rebuilt signature therefore attests where a bundle sits, not that its contents are unchanged — which makes an honest repair and a laundered substitution identical from inside the command, so every run says which it did. Never run a reindex because another machine reported a signature mismatch: it signs the bytes being complained about and the complaint stops. hub reindex --unsigned is the way out — it rebuilds every record except the statements, so peers keep reading them as unsigned until you have compared fingerprints out of band with hub trust, and a plain re-run afterwards restores the signatures.
Three limits, named rather than implied away. The statement also carries a digest of the project-tree artifact, and since 0.12.0 a pull checks it before unpacking (#110): a snapshot whose bytes are not what its signed statement names is skipped (workspaceUnverified) — nothing is written to the project, and the sessions import normally. That check exists only when a verified statement names the snapshot: an unsigned bundle sets no expectation, so a stripped signature switches it off — with a downgrade warning once this machine has pinned the sender, and silently before that (#107). A pin file this machine cannot read is treated as empty rather than blocking every pull, so a deleted or damaged pin store re-pins every peer on first use with only a warning; hub trust is where that shows, as every machine reporting unconfirmed. And the signing key is a second key, deliberately: it is not covered by hub escrow, because escrowing it would turn a passphrase that can read this machine's bundles into one that can forge its signatures — and losing it costs a re-mint and one hub trust round with each peer, where losing the encryption identity is permanent.
Workspace snapshots
For projects with no git remotes (including remote-less git repositories), push/pull also carry a workspace snapshot — a copy of the project's files, not just its Claude Code sessions — because there's no git remote to reconstruct the project from otherwise. Skip it with --no-workspace on push. To exclude specific paths from the snapshot (large build artifacts, secrets, anything you don't want copied into the hub), add a .sesh-mover-ignore file at the project root (one pattern per line, # comments allowed; matched against path segments, with * wildcards — not full .gitignore semantics; dir/ and dir mean the same thing). .sesh-mover-ignore also works in the other direction: when a pull merges an incoming payload, a path it names is not applied and your local file there is left alone (reported in the merge's skipped list). --force-workspace does not honor that — it takes the hub's copy wholesale.
A snapshot (and the git carry below) rides a bundle, and a bundle is only written when a session has something new in it: with no new messages, push reports upToDate before writing anything at all. So editing project files without talking to Claude Code publishes nothing — the files travel with the next push that has conversation in it.
A snapshot is a plain file copy: it does not read .gitignore. Four things keep a file out of it: .sesh-mover-ignore, the built-in excludes (.git, .claude, .sesh-mover, node_modules, .venv, __pycache__, .DS_Store), symlinks (never followed, never copied — reported as symlinksSkipped), and a size budget — 50 MB by default, hub.workspaceMaxMb to change it — over which the whole snapshot is declined with a warning rather than partially copied. A remote-less git repository takes this path, so a gitignored .env sitting in one is copied into the bundle unless .sesh-mover-ignore names it — that is the opposite of the git-carry rule below, and the difference is which payload builder ran, not which files you have.
.claude is on that list for a privacy reason, not a size one. The project-local Claude Code directory holds settings.local.json — permission allowlists, which routinely name paths and hostnames — plus any project hooks and agents, and if your CLAUDE_CONFIG_DIR points somewhere inside the project it holds the transcripts themselves, which would then travel a second time inside the project payload rather than the session payload. Since 0.6.0 none of it is snapshotted. It is a default rather than a hard rule, so .sesh-mover-include names it back if a shared .claude/settings.json or a set of committed agents is content you do want on your other machines — unlike .git and the paths sesh-mover owns, which no pattern can re-include. In a project with a git remote the split below applies to it like any other path: a committed .claude/settings.json you have edited still travels in the patch (it is tracked project content you chose to commit), while an untracked .claude/settings.local.json no longer does.
The opposite lever is .sesh-mover-include, a root dotfile beside .sesh-mover-ignore and with the same wildcards — but note one deliberate difference, spelled out below: a trailing slash is decoration in .sesh-mover-ignore and meaningful in .sesh-mover-include. Paths listed there are carried even if .sesh-mover-ignore, the built-in excludes or .gitignore would drop them (.gitignore only ever applies to the git carry below, where it decides which untracked files travel). It is meant to be committed, so one line fixes a project on every clone and every machine — a gitignored docs/ directory holding a project's design notes and plans is the motivating case, and the one to reach for first. Committing it takes nothing special: the three files a project is meant to keep — .sesh-mover-include, .sesh-mover-ignore and the hub identity .sesh-mover-project.json — are ordinary root dotfiles no ignore rule touches, and the one path to ignore is .sesh-mover/, where generated exports and parked carry payloads land. A plain .sesh-mover/ line does it, with no negation — which is the point of splitting the two lifecycles: a single directory holding both meant that ignoring your exports also silently stopped the include list and the identity file being committed, the one thing that makes them work.
A pattern with a / anywhere in it — trailing one included — is rooted at the project: docs/ is docs at the top level and everything under it, never a docs nested somewhere else; docs/superpowers/ carries that subtree, docs/*.md those files. Only a pattern with no separator at all matches that name at any depth (*.keepme). This is the difference from .sesh-mover-ignore, where patterns are matched per path segment and a trailing slash is stripped: docs/ there excludes a docs at any depth, while docs/ here names back only the top-level one. Some paths can never be re-included, by any pattern or spelling: .git, and everything sesh-mover owns — .sesh-mover/, .sesh-mover-include, .sesh-mover-ignore and .sesh-mover-project.json. The dotfiles need the floor more than the directory does: they are files at the project root, so a payload can name one directly with no directory in the way. Treat the file as security-relevant — .gitignore is also where .env lives, so list only what you mean to upload. It applies to both payload builders: the workspace snapshot for a project with no remotes, and the git carry below for a project with one. A push that re-includes gitignored files names them back to you in carry.reIncluded and in its warnings.
Applying a workspace snapshot: 3-way merge, never a silent overwrite
A pull applies an incoming snapshot one of three ways, and the result says which:
- Merge — when this machine and the payload share a generation (a snapshot both trees are known to have passed through, matched by bundle id). Every file is decided against that common ancestor: unchanged here → take theirs, unchanged there → keep yours, changed on both → a real 3-way merge via
git merge-file. Reported asworkspaceMerge. "Take theirs" replaces your file atomically with no sidecar and no backup, so the pull names the files it replaced (the first five, and how many more), the generation it merged against, and the hub file that still holds what they held. - Unpack — into an empty project directory, or when you pass
--force-workspace, which means "give me the hub's copy wholesale" and overwrites files of the same name. Reported asworkspaceUnpackedalone. - Skip — a directory that already has content with no shared generation to merge from. Nothing is written, and the sessions still import normally. The warning names
--target-path <fresh-dir>(unpacks elsewhere, destroys nothing) and--force-workspace(overwrites) — but treat those as applying to the next payload rather than as a retry of this one: the pull has already recorded its bundles, so an immediate re-run can answer "already up to date" before it reaches the files. Nothing is lost either way; the payload stays in the bundle on the hub. This state does not clear itself. A skip applies nothing, so it records no generation, so the next payload from that machine has nothing to merge against either and skips for the same reason — and every workspace bundle written before 0.6.0 declares no ancestor at all, which is exactly how the first 0.6.0 pull of an existing hub project lands here. Getting to the merge takes one deliberate application into this directory, and only--force-workspaceon a later pull is that: it takes the hub's copy over the current files, after which both trees share a generation and later payloads merge.--target-path <fresh-dir>on a later pull is the non-destructive way to see a payload — it unpacks elsewhere and touches nothing here — but sesh-mover keys its bookkeeping off the effective project path, so the generation it records belongs to that fresh directory and pulls into the original go on skipping identically. It does leave one thing behind in that directory. A pull that delivers a payload there also plants.sesh-mover-project.jsonin it, which links that directory to the hub project — and linking is the consent gate for the session-end auto-push (Automation above). Looking at the files is still inert; what is not is starting a Claude Code session in that scratch directory, because its session end would then push that directory's contents to the hub.sesh-mover hub unlink --project-path <that-dir>(or/sesh-mover:hub-unlink) is the supported way to undo that, and deleting.sesh-mover-project.jsonthere by hand does the same thing — the gate is that one file's existence, nothing else. Either way it is a root dotfile rather than something the.sesh-mover/directory beside it contains; that directory holds any carry payload parked there and can be deleted separately.
What the merge will and won't do:
- Conflict markers are normal. A file both sides changed in the same place comes back with markers in it —
<<<<<<< local,||||||| ancestor,>>>>>>> incoming— and is listed inconflicted. Resolve them the way you would a git conflict. - Nothing is merged blindly. A file both sides changed that can't be merged — a binary, one
git merge-filefails on, or any of them if there's no usablegit merge-fileon this machine — keeps your copy, with the incoming one parked beside it as<name>.theirs-<timestamp>(sidecars). Delete those once you've reconciled — otherwise the next push carries them back to the hub. - Some paths get nothing written near them at all. If a symlink or a directory occupies the destination (or one of its parent segments), the file is reported in
skippedand no sidecar is left either — parking one beside adocs -> ~/notessymlink would escape the project exactly as the original write would. The incoming copy is still in the bundle on the hub. Files your own.sesh-mover-ignoreexcludes land here too (locally-excluded), which is a rule on this machine rather than a fault. - The merge never deletes. A file the other machine deleted is kept here and reported (
upstreamDeleted) — delete it yourself if you agree. Symmetrically, a file you deleted that nobody changed upstream stays deleted (localDeleted), while one you deleted that was changed upstream comes back (restored), because that edit exists nowhere else.localDeletedis a report, not a verdict: a path an earlier merge couldn't write to lands in the same list, so it doesn't prove you deleted anything. - Falling back is safe by construction. Without a common ancestor, sesh-mover refuses to guess one — no timestamp is consulted anywhere in that decision, because the hub stamps nothing and every clock in it belongs to the machine that pushed. An older-than-necessary base fails toward "keep local, show a conflict"; a wrong newer one would fail toward silently overwriting your work, so it is never chosen.
- The floor can never be applied, whatever a bundle says:
.git, and every path sesh-mover owns (.sesh-mover/,.sesh-mover-include,.sesh-mover-ignore,.sesh-mover-project.json) — at any depth, in any casing (workspaceRefused).
Uncommitted work (git projects)
For a project with a git remote there is no workspace snapshot — git already has the committed state — so push carries the part git does not publish: a git diff HEAD patch (staged and unstaged, binary-safe) plus untracked, non-gitignored files, under carry/ in the bundle. Skip it with --no-carry, or turn it off everywhere (including for the SessionEnd auto-push, which takes no flags) with sesh-mover configure --set hub.carryDiff=false.
The carry has two halves and they obey different rules, so it is worth being precise about which is which.
Untracked files — the copied half. What never travels: gitignored files unless .sesh-mover-include names them, symlinks, .git, the paths sesh-mover owns, .claude, and anything else .sesh-mover-ignore or the built-in excludes drop. The whole payload is declined if it exceeds the carry budget — 50 MB by default since 0.7.0, hub.carryMaxMb to change it (reported as a warning naming the largest contributors). It was 5 MB, on the reasoning that a carry is a diff and therefore small; measured, that is simply not true of uncommitted work — this repository's own untracked .superpowers/ notes are ~12.6 MB, so the carry declined on the very repo that produced the tool. Each file also costs 512 bytes against the budget on top of its own size, so a tree of hundreds of thousands of empty files cannot slip a byte-only limit.
Two things about that number are worth knowing before you raise it. The decline is not retryable on demand: the carry rides a bundle, so an immediate re-push answers upToDate and the new budget takes effect on the next push that has new session content — which is why it is a config key and not a flag (the push that matters most is the unattended session-end one, which takes no flags at all). And the carry rides a bundle pushed on every session end, and the hub keeps every bundle, so a 50 MB carry on a synced folder is 50 MB of sync traffic per session end. 0 means carry nothing; a value that is not a size falls back to the default with a warning rather than silently; anything over 1024 MB is clamped, because both payloads are held in memory whole to build and to check.
Tracked files — the patch half. None of those rules filter the patch. git diff HEAD describes every tracked file you changed, so a change to a file that is gitignored and tracked travels in full: the common shape is a .env that was committed once and gitignored later without git rm --cached, and git add -f produces the same thing. .sesh-mover-ignore does not stop it either. Push names that set back to you in carry.trackedIgnored and in a warning; the remedies are git rm --cached <path> or --no-carry. On the automatic session-end push that warning has no one to print to — it is recorded in sync-state instead and reported by sesh-mover hub status as lastAutoPush, which is worth checking once after linking a project you have gitignored-but-tracked files in. One rule does cover the patch — the floor: .git and every path sesh-mover owns are excluded from it as well, so a tracked .sesh-mover/config.json (which can redirect hub.path) or .sesh-mover-include never rides along. .claude is deliberately not in that floor, only in the built-in excludes, which filter the untracked half alone — so a committed .claude/settings.json you have edited travels in the patch like any other tracked file you chose to commit, while its untracked settings.local.json sibling does not.
The patch records the working tree as it stands, so capturing during a conflicted merge or rebase captures the conflict markers too; the push says so, and the operation itself does not travel. The carry is for the work you have not committed, not a replacement for git — push your branch as usual.
On the other machine a carry is never applied on its own. pull reports it (carryAvailable) and only pull --apply-carry writes it into the working tree, and only when every guard passes: this machine is inside a git repository, its HEAD is the exact commit the patch was taken against, no merge/rebase/cherry-pick is in progress, there are no uncommitted changes to tracked files in the project, and git apply --check agrees. Those guards are what make it reversible — with a clean tree at the same commit, git checkout -- . undoes the patch half and the copied untracked files are listed in the result. Untracked files are never overwritten: an identical file is a no-op, a differing one is kept and the incoming copy lands beside it as <name>.incoming-<timestamp>. A patch that names anything on the floor — .git, or a path sesh-mover owns, in any casing or dot spelling — or that would create a symbolic link is refused whole, not partly applied.
Anything that stops the automatic path — a different commit, your own uncommitted work, no --apply-carry at all — parks the entire payload in .sesh-mover/carry-<timestamp>/ with a README.md explaining what happened, and the exact commands to finish by hand. Two cases withhold those commands on purpose and say so instead: a payload the floor refused, and a patch this machine's git could not parse. That is deliberate: a pull records its bundles as received, so re-running it never offers the same carry again, and the saved copy is the only remedy that actually works. Those directories are self-ignoring (they carry a .gitignore of *, so a peer's uncommitted work can never be committed here by accident) and only the most recent few are kept.
Three machines: a thread whose history is split across machines
Each machine's hub index lists only the bundles it pushed, so a conversation started on A, continued on B and pulled back to A leaves half its bundles listed by A and half by B. A pull used to read exactly one of those lists — the one belonging to the machine holding the thread's latest copy — so on a third machine C the conversation arrived in halves, with no error, no fork and no failure.
A pull now assembles the thread across every machine's list (#35). Each bundle records the entry it continues from, so a pull follows those links and fetches the whole chain in order, whichever machine listed which part — C gets the conversation entire. The order comes from the links alone and never from a timestamp: the hub stamps nothing, so pushedAt is the pushing machine's clock and ordering by it would silently revert work under clock skew.
What a pull still cannot deliver, it names rather than swallowing — and each condition is a separate result field, so a caller never has to read the wording to know which one it hit: chainGaps (a bundle's recorded link points at an entry no bundle on the hub carries), parkedBranches (a branch it did not follow when a thread forks), unplaceableBundles (a bundle pushed before this release recorded links at all — unlinkable by construction, not missing, and it re-links the next time that machine pushes), unwalkedRoots (the thread has more than one independent starting point, which a compaction routinely produces, and a pull walks one of them), and advertisedUnshipped (a machine advertising newer work it has never uploaded). unfetchableBundles says which machines hold what was left behind, and each field arrives with the warning that explains it. A pull that finds one of these and correctly applies nothing is a success that says why, not an error you can retry.
The shape that does have a remedy — and it needs no flag — is the one where the newest copy of the thread is this machine's own while another machine still lists bundles that never arrived here. pull used to refuse that outright ("the latest copy of this thread is already local"), which is a statement about heads answering a question about bundles. It now falls back to the machine that still has something for you, fetches it, and says so in a warning; a plain sesh-mover pull --thread <id> is the whole remedy. The session-end auto-push makes that shape routine: you pull, leave a divergence undecided, your own session ends and is pushed, and now your machine holds the newest head while the other machine's bundle is still sitting on the hub unreceived.
pull --latest, pull --thread <id> and whereis all decide that the same way, by asking whether any bundle in the thread's assembled chain has yet to arrive here. They used to disagree: --latest compared heads and --thread compared receipts, so a thread whose newest head was already here while a bundle for it was not got "nothing to pull" from one and a fetch from the other, and whereis reported pullNeeded: false about it either way (#44). That question is now asked of the whole chain rather than of one machine's list, so whereis's pullNeeded and the session-start notice are right about a split thread too — the indefinite "you are behind" nag on a thread no pull could complete is gone with the defect that caused it.
One case is worth knowing about after upgrading: bundles already sitting on a hub were pushed before links were recorded, so nothing can chain onto them. A pull still fetches them exactly as it did before — assembly widens what a pull reaches, it never narrows it — but a thread made only of such bundles is not assembled, it is merely fetched, and a new bundle pushed on top of an old one is reported as unlinkable rather than as missing. Each machine repairs its own share the next time it pushes that session. Nothing is lost either way: every bundle stays on the hub.
Compacting a thread
A thread pushed for months is one root bundle plus a long tail of continuations, and a fresh machine has to fetch and apply every one in order. sesh-mover hub compact --thread <id> (or /sesh-mover:hub-compact) replaces the tail with a single full bundle and then deletes the tail — in two phases, on two separate invocations. The first re-uploads the whole transcript (the cost worth knowing on a metered link) and deletes nothing; the second deletes, and only once every machine has demonstrably received the content and a six-hour grace window has passed, the window being the only defence against a pull that is in flight right now. Until then it answers compaction-pending and names which machine is behind — the normal answer, not an error — and a pull that meets a bundle the compaction has already removed names the consolidated bundle that replaced it rather than reporting a sync lag. Two limits follow from the hub's own rules. It retires this machine's bundles only — a chain can span machines, and one machine deleting another's files is what per-machine ownership forbids — so each machine reclaims its own share. And a bundle is retired only when the consolidated transcript demonstrably contains its head, never merely because it sits on the same chain: a parked fork chains perfectly and holds entries no export of the surviving branch will ever have, and after consolidation the hub may hold the only copy of that work, so those are kept and listed in retained. It is the one verb here that deletes data you did not name a file for, so it has no config key, no default thread, and is unreachable from the session-end auto-push.
Same-machine lock
Push and pull take an advisory lock per project while they run, so two hub operations for the same project on the same machine can't race each other and corrupt the hub's index. This is not a distributed lock — two different machines can (and normally do) push or pull concurrently; the hub's append-only bundle/index design is what keeps that safe, not the lock. If a command reports reason: "lock-busy", wait a few seconds and retry once.
What a pulled continuation lands as
When a pulled bundle continues a session this machine already has, pull appends the new entries to the end of that local transcript, so the thread stays one resumable session (appended, naming the baseSessionId to resume). That is the ordinary case for a two-machine round trip: work on A, continue on B, pull back to A, and A's original session simply has B's messages at the end of it.
Three things make it fall back to importing a separate session instead, and the pull says which:
- the local session was written in the last five minutes. That looks like a live Claude Code session, and sesh-mover cannot tell one from its own earlier pull, whose import stamps the same transcript — so a pull a few minutes after another one lands here with nothing running anywhere, and the warning names both possibilities rather than accusing a session that may not exist. That bundle is foreclosed: it has already arrived as a separate session and is recorded, so no re-run reaches it. The thread is not —
--force-appendon the next pull of it does splice that pull's continuation. The fix that needs no flag is preventive: close the Claude Code session writing to that transcript before pulling, and later continuations splice in normally. - no local session's entry chain lines up with the continuation — nothing to append to, and
--force-appendwill not help, because the chain guard is never skipped; - the bundle's content doesn't match its manifest hash, so it is never welded into a transcript you own.
--no-append (or hub.pullAppend=false) turns the whole path off. Either way the content arrives — the difference is one session or two. A separate session is registered fresh for claude --resume, and its first entry is a synthetic continuation header pointing at where the earlier messages actually live: the full original conversation is never silently merged into it.
Divergence — both machines continued from the same point. Then neither branch continues the other and there is no safe automatic answer, so pull reports a divergence object with both sides' entry counts and last-active times, and you choose:
- keep both (
--on-divergence fragment, the CLI default) — your session is untouched and the hub's branch is imported alongside it. Nothing is lost; the conversation stays in two transcripts. - adopt the hub's branch (
--on-divergence adopt-hub) — your session becomes the shared history plus the hub's branch, and your own branch is preserved in full as a new registered session. Three things worth knowing before you pick: the preserved session has no thread mapping, so your next push publishes it as its own thread; adoption truncates the session it rewrites, so it is refused outright while that transcript looks live; and the preserved copy is the transcript only — side files (subagent transcripts, tool-result blobs, file-history backups) stay attached to the session that was adopted. - decide later (
--on-divergence skip) — nothing is applied and nothing is recorded, so the same pull can be re-run with either answer. That holds for the whole thread, not just the bundle that diverged: a skip (and the liveness refusal of anadopt-hub) stops the rest of the chain too and says how many bundles it left, because the next bundle is anchored on the head the skipped one would have installed and would otherwise arrive as a third transcript with the answer silently dropped./sesh-mover:pullalways passes this first, then asks.
Is this bundle what it says it is?
Everything below is damage detection — an incomplete transfer, a half-finished unpack, a bit that flipped on a network share. None of it is attestation, and the hashes themselves are not signed on any transport: anyone who can rewrite a bundle can recompute every hash in it. What differs is whether anything covers the rewrite. On an export bundle nothing does — the file carries no signature, and trusting the sender is a question import cannot ask; --apply-workspace / --apply-carry is you answering it. On a hub bundle pushed since 0.11.0, the archive's digest is in a statement the pushing machine signed, so rewriting the archive means re-signing it too — and what stops a tamperer doing exactly that is the key you pinned, not the signature (Bundle signing). A hub bundle without a signature is checked exactly as an export bundle is, and the hub's trust model covers the rest.
A bundle's manifest.json carries three layers of hash, checked in this order before anything is written:
sessionsDigest— one digest over the whole declared session inventory (each session's id, itsintegrityHash, its layer digests, its continuation linkage). It is what catches a manifest that is internally self-consistent but wrong: a session record dropped from the list, or a hash edited to match content that was altered. Every per-session check would still pass in both cases, because a per-session hash is only ever consulted for a session the manifest still lists.integrityHash, per session — sha256 over the session JSONL as the exporter wrote it.layerDigests, per session per layer — one digest each for that session'ssubagents,tool-resultsandfile-historydirectories, over the sorted (name, content-hash) pairs.
What each failure does:
- A session the manifest declares but the bundle does not contain fails the import outright, before anything is written. Until 0.6.0 that case was never checked — the session was counted as imported and no file was written for it, so a bundle holding no session data at all reported
success: true, imported: 1, warnings: []. On amigratethat was destructive, because the source is deleted on the strength of that list.--session-idstill imports the sessions that are present. - A damaged session inventory fails the import, and on the hub side fails that
pull— before the workspace merge, before the carry, and before a continuation can be spliced into a transcript you already own. - A session whose bytes don't match its own hash is imported with a warning (the content is the only copy you have), but is never spliced into an existing transcript — see What a pulled continuation lands as.
- A layer whose digest doesn't match is not copied, and the import says which session and which layer. A
file-historyentry is a backup Claude Code may later restore over your own file, so a backup that cannot be shown to be the backup that was taken has no business landing where something may restore it. The transcript itself imports normally.
Layer digests are one per layer rather than one per file. Detection is identical either way — any change to any file in the directory changes the digest — and per-file localisation buys nothing, since the response is the same for one corrupt file as for ten. The cost is not hypothetical: a single real session was measured holding 106 file-history entries, which is ~10 KB of manifest for that one session.
Encrypted bundles do not change any of this. All three hashes are computed during export, over plaintext, in the staging directory — long before the archive exists and therefore long before any encryption. Their question is "is this the file the exporter wrote", and a ciphertext hash would answer a different one that the AEAD tag already answers per chunk and better. manifest.json also lives inside the archive, so on an encrypted bundle it is inside the ciphertext and there is nowhere for such a hash to sit. What encryption does add, for the bundles that have it, is that a silent bit flip becomes impossible rather than merely improbable — but encryption on its own adds no attestation, for the reason given under Encryption at rest. That is bundle signing's job, it is a separate mechanism, and on the read side it covers the archive and, since 0.12.0, the split workspace artifact — for signed bundles only.
Archive framing. A .tar.gz carries a CRC32, so both a truncated and a bit-flipped one fail loudly on extraction. A .tar.zst is only as loud because the zstd CLI writes an XXH64 frame checksum by default — with --no-check, a flipped byte decompresses silently to different content. Since 0.6.0 that default is verified rather than assumed: an archive whose frame carries no content checksum is never handed back as a bundle (the export falls back to gzip and says so), and one arriving from elsewhere without a checksum extracts with a warning, since the manifest hashes above cover its contents anyway.
Security notes
Exports are faithful copies of your conversation — they can contain API keys, pasted secrets, and environment dumps. Treat them like ~/.claude/projects/ itself. As of 0.3.2, imported bundles are validated to reject session ids that would enable path traversal outside the bundle.
- Sessions are secrets. Prefer user-level storage, or add
.sesh-mover/to the project's.gitignore— a plain line, since the files a project is meant to commit are root dotfiles outside that directory. - An archive on a cloud drive or in Slack is a copy of your conversation history. Move it like a secret. The same is true of anything pushed to a hub directory — see The Hub's trust model above, and Encryption at rest for what sealing a hub does and, more importantly, what it does not do to what is already on it.
- Nothing is redacted automatically. Inspect before sharing;
--no-summaryhides excerpts from listings but does not redact the JSONL.
Platform support
- macOS, Linux, WSL1/WSL2, Windows (native) — full support; CI runs the test suite on all three OSes plus a PowerShell and Git-Bash export/import smoke round-trip on Windows. WSL is auto-detected and paths translate to/from Windows peers (
/home/u/...↔C:\Users\u\...,/mnt/c/...↔C:\...), including inside tool output. - Known limitation: in free text, a path containing spaces only translates fully when it's under the project/config/home mappings; structured fields (
cwd, file-history keys) always translate fully. Details inCLAUDE.md.
Development
npm install
npm run build # src/ → dist/ (dist/ is committed — rebuild after src/ changes)
npm test # vitest run
npm run lint # tsc --noEmit
Single file: npx vitest run tests/rewriter.test.ts · by name: npx vitest run -t "translates WSL paths". Architecture and conventions: CLAUDE.md.
License
MIT. If this saves you a headache, a ⭐ is appreciated.