# Cowork Migrate

> Use when moving, importing, copying, or migrating a Claude Cowork session, conversation, or project from one Windows machine to another, or when troubleshooting symptoms of a broken import like "session shows blank", "only the latest messages show", "scratchpad files don't open", "can't scroll past the last compaction", or "Loaded N messages (truncated via tail/compaction)" in the Cowork log. Delivers full history, working file links, and no truncated-transcript rendering bug, handling the undocumented two-layer compact_boundary truncation filter in app.asar that silently clips imported transcripts. Covers orphan sessions on Windows to Windows under the same Cowork account. Does not handle Cowork Spaces/Projects, Linux/macOS, or cross-account migration.

- Skill: `aeyeops/cowork-migrate` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add aeyeops/cowork-migrate`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aeyeops/cowork-migrate/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: AeyeOps (https://skillmd.com/u/aeyeops)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/aeyeops/cowork-migrate

---


# cowork-migrate

Migrate one Claude Cowork session from a source Windows machine to a destination
Windows machine so that the full transcript renders, file links resolve, and the
scratchpad metadata survives Cowork's first-load sidecar rewrite.

This skill is **rigid**: the truncation-filter workaround in particular has a narrow
winning configuration, and skipping steps produces subtle partial-history bugs that
look like success. Follow the workflow in order.

## When this skill applies

Trigger on any of:
- Explicit intent: "migrate my cowork session/conversation/project from X to Y",
  "import this cowork session", "bring my cowork work over", "copy cowork from
  my laptop to my desktop"
- Symptom reports after a manual copy attempt:
  - Session visible in list but opens blank
  - Scratchpad shows reviewed files but clicks go nowhere
  - History cuts off at last compaction; older messages missing
  - Log shows `Loaded N messages from transcript ... (truncated via tail)` or
    `(truncated via compaction)`
  - Log shows `Transcript not found` or `Filtering out deleted folder`
- Questions about Cowork storage layout, sidecars, `local_<uuid>.json`,
  `sessiondata.vhdx`, `compact_boundary`, `preservedSegment`

This skill covers single-session migration on Windows to Windows under one Cowork
account. It does not cover Cowork Spaces/Projects (multi-session containers), Linux
or macOS Cowork, or cross-account migration; those need a different procedure. If
the user asks for one of those, say so and stop rather than improvising.

## Before acting: read the references

The on-disk structures this workflow manipulates (the profile path, the two session
IDs, the per-session files, the sidecar's host-path fields, the VM filesystem, and
the first-load sidecar rewrite) are documented in `${CLAUDE_PLUGIN_ROOT}/shared/cowork/storage-layout.md`.
Read it before starting.

The transcript truncation filter that step 6 fixes is documented in
`references/truncation-filter.md`, including the reverse-engineered `app.asar`
pseudocode and the two red-herring approaches that look correct but fail. Read it
before touching any JSONL.

## Preconditions

- Both machines run Claude Cowork for Windows and are logged into the **same Cowork
  account** (account UUID and profile UUID match across machines for the same login,
  so you do not invent new IDs).
- You can reach the source machine via SSH or have its files locally already.
- The user has agreed to **quit Cowork on the source machine** — the
  `sessiondata.vhdx` is locked while Cowork is running.
- You have the session's friendly title or `cliSessionId` prefix so you can
  disambiguate.

## Workflow

### 1. Inventory the source

Ask the source host for its sidecars and pick the right one by title. On Windows
via PowerShell locally, or remotely over SSH with `-EncodedCommand` to avoid
quoting pitfalls:

```powershell
$profile = "$env:APPDATA\Claude\local-agent-mode-sessions\<acct>\<prof>\"
Get-ChildItem $profile -Filter "local_*.json" | ForEach-Object {
    $j = Get-Content $_.FullName -Raw | ConvertFrom-Json
    [PSCustomObject]@{
        Id = $j.sessionId.Substring(6,8)
        Title = $j.title
        Folders = ($j.userSelectedFolders -join "|")
        VmName = $j.vmProcessName
        Cli = $j.cliSessionId
    }
}
```

Note the exact `sessionId`, `cliSessionId`, `vmProcessName`, and the list of
`userSelectedFolders`. You'll need all four.

### 2. Quit Cowork on the source

Confirm the user has quit Cowork so the `sessiondata.vhdx` lock releases. If you
skip this, `scp`/`cp` of the VHDX will either fail or capture a corrupt snapshot.

### 3. Pull the files

Three things to copy from source to destination:

1. **The sidecar**: `local_<uuid>.json`. Save two copies on the dest: one as a
   pristine reference (e.g. under `/tmp/` or `C:\Users\<dst>\cowork-archive\`), one
   to install into the profile directory after path rewriting.
2. **The transcript dir**: the entire `local_<uuid>/` directory, including
   `.claude/projects/<encoded-cwd>/<cliSessionId>.jsonl` and every
   `subagents/*.jsonl`. Copy it to the dest at the identical profile path.
3. **The VM session files**: mount `sessiondata.vhdx` read-only, extract files
   from `/sessions/<vmProcessName>/` that the user cares about (skip
   `node_modules`, `.cache`, `.local`, `.npm*` — those are junk). Write them to a
   well-known host directory on the dest, e.g.
   `C:\Users\<dst>\cowork-archive\<vmProcessName>\`. This becomes the rewrite
   target for broken `/sessions/<vmProcessName>/...` references.

   VHDX extraction (run on the dest machine after copying the VHDX there):
   ```
   wsl --mount --vhd 'C:\full\path\to\sessiondata.vhdx' --bare
   # inside WSL:
   lsblk                                           # find device (labeled `sessions`)
   mkdir -p /mnt/cowork-sessions
   mount -o ro /dev/sdX /mnt/cowork-sessions
   # copy files from /mnt/cowork-sessions/<vmProcessName>/ out to the host
   umount /mnt/cowork-sessions
   wsl --unmount 'C:\full\path\to\sessiondata.vhdx'
   ```

   If running WSL from git-bash, prefix the command with `MSYS_NO_PATHCONV=1` to
   stop the shell from rewriting `/mnt/c/...` arguments.

### 4. Rewrite paths in every JSONL + the sidecar

Transcripts reference absolute paths from the source machine. Four classes:

| Class | Pattern | Rewrite target |
|---|---|---|
| Source username in Windows paths | `C:\Users\<srcuser>\...` | Dest username |
| Source-specific folder layout | e.g. source has a junction or symlink that the dest lacks | Ask the user what the right dest path is |
| VM bind-mount paths | `/sessions/<vm>/mnt/<mountname>/...` | The real host dir the mount pointed at on source — **which is the folder whose basename matches `<mountname>`** in `userSelectedFolders`. E.g. a `userSelectedFolders` entry of `C:\Users\<srcuser>\Documents\work` produces a VM bind mount at `/sessions/<vm>/mnt/work`. Use that correspondence to discover what to rewrite to. |
| VM working-dir paths | `/sessions/<vm>/...` | Wherever you extracted VM files to on the dest. Do **not** rewrite the `.claude/projects/-sessions-<vm>/` subdirectory name itself — that encoded-cwd string is the directory layout Cowork expects and stays unchanged. Only `cwd` **values** inside transcript event JSON get rewritten. |

In the sidecar, the class-1 username rewrite also has to reach the top-level `cwd`
value and every `fsDetectedFiles[].hostPath`, not just the `userSelectedFolders`
entries (see `${CLAUDE_PLUGIN_ROOT}/shared/cowork/storage-layout.md`). The whole-file pass below does this
automatically; flagged here so a hand rewrite of only the folder fields does not
silently skip them.

Use `scripts/rewrite-paths.py` as a template. Copy it into a working dir, edit the
`SRC_USER`, `DST_USER`, `VM_NAME`, `VM_FILES_DEST`, and `RULES` constants at the
top for this migration's specific source→dest mappings, then run it against every
file that needs rewriting. The template ships with all rules **commented out** and
marked with placeholder usernames — you must uncomment the rules relevant to this
migration before running, otherwise the script is a no-op.

**Critical**: Cowork's file sandbox will only open files that live under an entry
in the sidecar's `userSelectedFolders`. After you decide where to stage the
extracted VM files (typically under `C:\Users\<dst>\cowork-archive\<vmname>\`),
you must **append that staging dir to `userSelectedFolders` in the rewritten
sidecar**, otherwise every click on a VM-origin file in the Cowork scratchpad
will warn `readLocalFile: path ... resolves outside allowed folders` and fail.
This is in addition to — not a replacement for — keeping the user's original
selected folders (dest-equivalent).

```
# dry run against one file to see matches before committing
python rewrite-paths.py --count path/to/local_<uuid>.json

# rewrite a single file in place (timestamped .bakN backup is written first)
python rewrite-paths.py path/to/<cliSessionId>.jsonl

# rewrite every *.json and *.jsonl under the session working dir in one shot
python rewrite-paths.py --dir path/to/local_<uuid>/
```

Apply the rewrite to every file: `local_<uuid>.json`, the main transcript JSONL,
and every `subagents/*.jsonl`. The script keeps backups so repeated runs are safe.

Why bytes matter: inside a JSONL file a Windows path separator is stored as two
literal backslash bytes, which appears as `\\\\` in a raw JSON string. The rewrite
script handles the escaping for you — as long as you write mappings in plain
Windows-path form (e.g. `rf"C:\Users\{DST_USER}\cowork-archive"`), the helpers
produce the right byte sequences. Do **not** try to hand-edit JSONL with
`grep ''` or `sed` — you'll miscount and miss references.

### 5. Restore the pristine sidecar's userSelectedFolders after first Cowork load

Start Cowork on the dest. The session appears in the list but Cowork will silently
strip any `userSelectedFolders` entries whose paths don't exist on the dest. This
is why step 3 stashed a pristine copy. Workflow:

1. Ensure the dest folders from the rewritten sidecar actually exist on disk.
   Create empty dirs if necessary, including the `cowork-archive/<vmname>`
   staging dir you added to `userSelectedFolders` in step 4.
2. Quit Cowork on the dest.
3. Copy the **rewritten pristine sidecar** over the one Cowork just mangled.
4. Restart Cowork.

**Filename convention for the two sidecar copies**: when you stash the source
sidecar in step 3, save it as
`<dst_archive>\<short-id>-pristine-sidecar.json` and keep it untouched. Save
the post-rewrite version as `<dst_archive>\<short-id>-rewritten-sidecar.json`.
The "pristine" name refers to "unmodified by Cowork's first-load rewrite", not
"unmodified by our path-rewrite script" — it's the pre-Cowork-mangle version we
want to put back in place, and it must have path rewrites applied before we put
it back.

### 6. Fix the compact_boundary truncation (skip if no boundaries)

**Fast path**: if the transcript JSONL has **zero** `compact_boundary` events
and is **≤ 50 MB**, you can skip this entire step. Cowork's layer-1 file reader
(`cDn`) reads the whole file in that case, and layer 2 (`lDn`) has no boundaries
to filter against, so every event loads. Quick check:

```
grep -c '"subtype":"compact_boundary"' <transcript>.jsonl    # want: 0
stat --printf='%s\n' <transcript>.jsonl                       # want: ≤ 52428800
```

`scripts/chain-walker.py` and `scripts/stitch-boundaries.py` both detect the
no-boundary case themselves and exit 0 with a "NO STITCH NEEDED" message, so
running them against a no-boundary transcript is safe and informative.

If the transcript has ≥1 `compact_boundary` event, continue below.

Even with the transcript in place, Cowork will likely show only messages after the
last compaction. The log signal is
`Loaded N messages from transcript for session <id> (truncated via compaction)` or
`(truncated via tail)` at
`C:\Users\<dst>\AppData\Roaming\Claude\logs\main.log`.

**Read `references/truncation-filter.md` before running the stitch script** — it
documents why the naive "just rename `compact_boundary`" hack breaks the file
reader (layer 1) even though it bypasses the event filter (layer 2), and why you
must stitch the `parentUuid` chain and populate `preservedSegment` instead.

Then run:

```
python scripts/chain-walker.py <transcript>.jsonl
```

This simulates Cowork's walker and reports whether it currently reaches `headUuid`.
If not (and it won't, on the first run), apply the fix:

```
python scripts/stitch-boundaries.py <transcript>.jsonl
```

It makes a `.bak5` backup, reverts any `compact_boundary_disabled` subtype, sets
each boundary's `parentUuid` to the most recent uuid-bearing event preceding it,
and adds `compactMetadata.preservedSegment: {headUuid, tailUuid}` to the **last**
boundary. Re-run `chain-walker.py` to confirm `WALK OK: head reached in N steps`.

Apply the fix to the main transcript. Subagent JSONLs in `subagents/` do not go
through this filter and do not need stitching.

### 7. Verify

1. Start Cowork on the dest and open the session.
2. Tail the log:
   `C:\Users\<dst>\AppData\Roaming\Claude\logs\main.log`
3. Look for `Loaded N messages from transcript for session <id>` **without** any
   `(truncated via ...)` suffix. The number will be below total JSONL line count —
   that's normal, Cowork filters `queue-operation`, `last-prompt`, `progress`,
   `attachment`, and `isVisibleInTranscriptOnly` events.
4. Scroll to the top of the conversation in the UI and confirm you see the first
   user message.
5. Click a referenced file path — it should open. Paths that still 404 are usually
   files that only ever lived inside the VM and were never persisted to a
   host-mounted dir; those are genuinely gone (note this to the user as expected
   data loss).

Absence of `(truncated via ...)` plus a full-range scroll is the success
criterion.

## Important gotchas

- **Cowork rewrites the sidecar on first load.** Always keep a pristine copy.
  See step 5.
- **Raw backslash escaping in JSONL is confusing.** Don't hand-edit with
  `sed`/`grep` — miscounts are common. Use `scripts/rewrite-paths.py` with its
  byte-aware rule engine.
- **Cowork creates boundary pairs with duplicate UUIDs.** In sessions with
  many compactions, the second boundary of pair N shares its UUID with the
  first boundary of pair N+1. The bundled `stitch-boundaries.py` detects
  this and assigns fresh UUIDs to later occurrences before stitching. If
  you see `chain-walker.py` report a cycle, run `stitch-boundaries.py`
  first — it handles dedup automatically.
- **Renaming `compact_boundary` is a red herring.** It gets layer 2 to return
  "keep everything" but makes layer 1 fall back to tail-only reading for any
  file over the 50 MB reader threshold. Net result: partial history that looks
  like success. Use the stitch approach instead.
- **`isCompactSummary: true` is also a red herring.** It only hides an
  individual summary message in the rendered timeline. It does not affect
  either truncation layer. Don't waste time on it.
- **PowerShell 5.1 `Remove-Item` on junctions throws `NullReferenceException`.**
  Use `[System.IO.Directory]::Delete("C:\path", $false)` instead.
- **Files created inside the VM that were never persisted to a host-mounted
  dir are unrecoverable.** Click-to-open on those will 404 in the imported
  session. This is expected data loss, not a migration bug.

## Reference files

- `${CLAUDE_PLUGIN_ROOT}/shared/cowork/storage-layout.md` — the on-disk storage layout: profile path, the
  two session IDs, per-session files, the sidecar's host-path fields, the VM
  filesystem, and the first-load sidecar rewrite. Read before starting.
- `references/truncation-filter.md` — reverse-engineered `app.asar` pseudocode of
  the two-layer truncation filter (`cDn`, `sDn`, `iDn`, `lDn`), the winning stitch
  configuration, and the two red-herring approaches that look like they work but
  don't. Read before touching the JSONL.
- `scripts/stitch-boundaries.py` — fixes the transcript.
- `scripts/chain-walker.py` — validator that simulates Cowork's parentUuid walk
  and reports whether it reaches `headUuid`.
- `scripts/rewrite-paths.py` — template for path rewrites. Copy, edit the
  `SRC_USER`/`DST_USER`/`VM_NAME`/`RULES` constants for the specific migration,
  then run against every JSONL and the sidecar (or pass `--dir` to walk the
  whole session working dir in one shot).

