Repository navigation
Conversation
The desktop app renders its sidebar from its own index of pointer records at claude-code-sessions/<account>/<workspace>/local_<uuid>.json, each naming a transcript, rather than from ~/.claude/projects. Syncing ~/.claude alone therefore restores conversations for `claude --resume` while leaving the desktop sidebar empty. This package reads and writes that index: - Records are backed by a field map rather than a struct. The schema belongs to the app and gains fields across versions; a struct would silently drop anything not modelled here every time a record was rewritten. - Archive state is tri-state. It exists only in this index, so a machine without the desktop app knows nothing about a session, which is distinct from knowing it is active. ArchiveUnknown is the zero value so a status that was never populated cannot be mistaken for "active" and un-archive the session on another device. - Sessions are matched by cliSessionId, the identity that is stable across machines. The app's own sessionId is minted per device. - Record file names are refused rather than cleaned when they are not plain file names, because sessionId reaches this code from remote storage and becomes a filesystem write target. The macOS index location is verified against a real installation. The Linux and Windows locations follow Electron's userData convention and are untested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PushDesktop and PullDesktop follow the existing MCP subsystem pattern: read locally, hash against sync state, then compress, encrypt and upload under an _external/ key. Because buildRemoteMap has always skipped _external/, the new object is invisible to the ordinary file-sync path, including in older clients, so no remote format migration is needed. The payload carries each record verbatim alongside the moment the pushing machine last observed it. The observation time travels beside the record rather than inside it: the record schema belongs to the desktop app and must not gain fields invented here. The record's own modification time serves as that moment, since the app rewrites a record when a session is archived or unarchived. Sessions the local sidebar has never seen get a record written. Sessions it already knows about keep their local record and have only their archive state reconciled, last-writer-wins, with an unknown incoming state never overwriting a known local one. --skip-archived omits archived sessions' transcript bodies from push while still sending their archive labels. Omitting the label too would mean a session archived after its first sync could never become archived on another device. The filter composes at the push call site only; pull and status keep the plain exclude set so the flag cannot change what a download or status report covers. A machine with no desktop index contributes nothing and is not an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Existing behaviour is unchanged when no new flag is given. `pull` on its own still touches only ~/.claude; `pull --desktop` additionally restores the sidebar. `push` gains no flag for the index because archive state has to travel on every push to stay convergent, and the index is small next to the transcripts already being uploaded. `desktop push` and `desktop pull` mirror the existing `mcp push` / `mcp pull` subcommands so the index can be moved on its own. Without them, restoring a sidebar would require a full sync — downloading conversations solely to fix the app's session list, with the conflict prompts that entails. Sidebar hydration is skipped during --dry-run, since it writes to disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a Desktop app sidebar section covering why the index exists separately from ~/.claude/projects, where it lives per platform, how dedupe avoids duplicate rows, and what --skip-archived does and does not hold back. The Linux and Windows locations are marked untested, since only macOS has been verified against a real installation. SECURITY-AUDIT gains L6, recording that pulling the index is the tool's first write outside ~/.claude and that record file names derive from remote data. The mitigations are stated along with the residual risk: an actor with bucket write access can still add sidebar rows naming arbitrary transcript ids, though nothing is executed and no file outside the index directory is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends claude-sync to also sync the Claude desktop app’s sidebar index (stored outside ~/.claude) so that conversations pulled to a new machine become visible in the desktop UI, not just resumable via claude --resume. It adds dedicated desktop push/pull commands, a pull --desktop flag, and a push --skip-archived option to avoid uploading archived transcript bodies while still syncing their archived state via the desktop index.
Changes:
- Add encrypted transport + apply logic for the desktop sidebar index under
_external/desktop-index.json.age, with dedupe bycliSessionIdand archive-state reconciliation. - Add CLI wiring:
claude-sync desktop {push,pull}andclaude-sync pull --desktop; always push the desktop index duringpush. - Add
push --skip-archivedto exclude archived transcripts from upload based on local desktop index labels, plus new tests and documentation updates.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
SECURITY-AUDIT.md |
Documents new write scope outside ~/.claude and traversal mitigations for record filenames. |
README.md |
Adds user-facing docs for desktop sidebar syncing and the new CLI flags/commands. |
internal/sync/sync.go |
Wires push scanning to use a push-specific excluder (for --skip-archived). |
internal/sync/skiparchived_test.go |
Adds tests for sessionIDFromPath and --skip-archived push behavior. |
internal/sync/desktop.go |
Implements PushDesktop/PullDesktop payload handling and --skip-archived filtering helpers. |
internal/sync/desktop_test.go |
Adds sync-layer tests for desktop index round-trip and no-op behavior. |
internal/desktop/scan.go |
Adds scanning of local desktop index records to extract archive labels and snapshots. |
internal/desktop/scan_test.go |
Tests desktop index scanning, keying, and mtime-based observation timestamps. |
internal/desktop/root.go |
Adds platform-specific discovery of the desktop app’s sessions directory. |
internal/desktop/root_test.go |
Tests platform-specific root path construction and Windows APPDATA fallback. |
internal/desktop/record.go |
Adds record model that preserves unknown fields and validates safe filenames. |
internal/desktop/record_test.go |
Tests unknown-field preservation, tri-state archive status, and JSON round-tripping. |
internal/desktop/index.go |
Adds discovery of the <account>/<workspace> index leaf directory under the root. |
internal/desktop/index_test.go |
Tests index discovery (single leaf), ambiguity handling, and missing-index behavior. |
internal/desktop/apply.go |
Adds application logic to write new records and reconcile archive state. |
internal/desktop/apply_test.go |
Tests dedupe behavior, traversal rejection, and last-writer-wins archive reconciliation. |
internal/config/config.go |
Introduces _external/desktop-index.json remote key constant. |
cmd/claude-sync/main.go |
Adds CLI commands/flags (desktop, pull --desktop, push --skip-archived) and integrates desktop push/pull helpers. |
cmd/claude-sync/desktop_test.go |
Tests that the desktop command exposes documented push and pull subcommands. |
Suppressed comments (1)
internal/desktop/apply.go:134
- reconcileArchive updates a local record based on a newer remote ObservedAt, but the subsequent Save() sets the file’s mtime to “now”. Since mtime is later used as the archive decision timestamp, this effectively rewrites history and can cause this machine to ‘win’ conflicts it didn’t actually decide. After applying a remote decision, set the record mtime to in.ObservedAt (when non-zero).
local.setArchiveStatus(status)
if err := local.Save(path); err != nil {
return false, err
}
return true, nil
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A sidebar record points at a transcript by id. Writing one for a session this
machine does not have produces a row that opens onto "Session not found on
disk": the index is intact, but the conversation behind it lives elsewhere.
`desktop pull` deliberately touches only the index, which is what makes it safe
to run on its own — but that also meant it would happily create rows for
conversations the machine had never received. Pulling the index before pulling
the conversations gave a sidebar full of dead entries and no indication why.
Records are now written only for sessions with a local transcript. The rest are
counted and reported with the remedy:
✓ Desktop sidebar: 84 added, 0 archive states updated
21 skipped — no transcript on this machine; run 'claude-sync pull' first
The check reads projects/<encoded-project>/<session-id>.jsonl from claudeDir,
which the syncer already knows, so internal/desktop keeps its independence from
the ~/.claude layout. `pull --desktop` is unaffected: it pulls conversations
before hydrating, so the transcripts are already present.
Found by using it — an index pulled ahead of its conversations on a second
machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new external desktop-index state entry will be misinterpreted as a deleted local file by Syncer.Push(), causing unnecessary remote deletes/reuploads and potential data loss if the process stops between delete and re-upload.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
internal/sync/desktop.go:190
PullDesktopunconditionally gzip-decompresses the decrypted payload. Other sync payloads (regular file sync, MCP) only decompress when the gzip magic bytes are present, which makes the read path more robust and backward-compatible if the payload format ever changes or is corrupted.
compressed, err := s.encryptor.Decrypt(encrypted)
if err != nil {
return nil, fmt.Errorf("failed to decrypt desktop index: %w", err)
}
data, err := gzipDecompress(compressed)
if err != nil {
return nil, fmt.Errorf("failed to decompress desktop index: %w", err)
}
internal/desktop/root.go:47
SessionsRootreturnsErrNoHomeDirimmediately whenos.UserHomeDir()fails. On Windows,APPDATAis the authoritative base path and may be set even when the home directory lookup fails, so this unnecessarily prevents desktop sidebar sync from working in some environments.
// SessionsRoot returns the desktop session index root for this machine.
func SessionsRoot() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", ErrNoHomeDir
}
return sessionsRoot(runtime.GOOS, home, os.Getenv("APPDATA"))
}
README.md:200
- The “What Gets Synced” section currently reads like the desktop sidebar index is always included in
pull, but the implementation only restores it when--desktopis used (or viadesktop pull). This could confuse users trying a normal pull and expecting the sidebar to populate.
Plus the desktop app's sidebar index, which lives **outside** `~/.claude` — see
[Desktop app sidebar](#desktop-app-sidebar).
internal/sync/desktop.go:181
PullDesktoptreats any download error as “nothing pushed yet”. That will silently hide real failures (auth/network/bucket misconfig) and lead to misleading CLI output. It should only setNoRemotewhen the remote object is genuinely missing (and otherwise return the error).
encrypted, err := s.storage.Download(ctx, config.DesktopRemoteKey+".age")
if err != nil {
// Nothing has been pushed yet. That is an empty result, not a failure.
result.NoRemote = true
return result, nil
}
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Lite
PushDesktop and PushMCP record their remote objects in state.Files so an unchanged payload is not re-uploaded. DetectChanges then read the absence of those keys from the local tree as a deletion, so every ordinary push deleted the object from the remote, dropped its state entry, and relied on the owning subsystem to re-upload it afterwards. That is a needless delete-and-reupload on every push, with a window in which an interrupted push leaves the object gone. Worse, if the owning subsystem has nothing to upload — the desktop app absent when push runs, no MCP servers configured — nothing re-uploads it and the remote object is simply lost for every other device. External keys now share one prefix constant, used by both the pull-side mapper (which already skipped them) and the push-side change detector (which now does). The test writes the index to the remote, runs an ordinary push, and asserts the object and its state entry survive. MCP was exposed to the same flap and is covered by the same change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A record's mtime doubles as its observation time on the next push. Apply left freshly written records stamped "now", so a device that had merely received a decision would later report it as though made locally at pull time. That inflated time could then beat a genuinely later change back on the device that actually made the decision, inverting last-writer-wins. Records now carry the incoming ObservedAt as their mtime, both when created and when an existing record adopts a remote archive decision. A zero ObservedAt carries no timing information and leaves the mtime alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing them PullDesktop treated any stat error on the index directory as "no desktop app" and any download error as "nothing pushed yet". A permission problem, an unreachable bucket, or a rejected credential therefore reported success with a benign message, sending the user to check the other machine when the problem was in front of them. Only a missing directory is now "no desktop app"; other stat failures are returned. For the remote, the storage layer has no not-found sentinel across providers, so a bare Download error cannot distinguish absence from failure. A prefix listing on the exact key can: an empty listing is a genuine absence, a listing error is a storage failure, and a download that fails after the key was listed is likewise a real failure. Nothing changes in the storage layer. PullMCP has the same shape and would benefit from the same treatment; left for a separate change to keep this one scoped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate review findings must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/claude-sync/main.go:1265
pull --desktopdoes not reach this block on the first pull when~/.claudealready contains files: the earlier first-pull branch returnshandleFirstPullWithExistingFiles, whose helper executes the file pull and returns without desktop hydration. The documented flag therefore silently omits the sidebar in this common first-run path unless--forceis used. Thread the flag through the first-pull helper or hydrate after that helper completes.
README.md:200
- The new documentation says the desktop sidebar is part of the normal
pullcontents, but the implementation deliberately restores it only forpull --desktop(andpushalways carries it). This contradicts the documented default behavior and can make users expect a plain pull to hydrate the app. Clarify thatpull --desktopis required for the sidebar.
Plus the desktop app's sidebar index, which lives **outside** `~/.claude` — see
[Desktop app sidebar](#desktop-app-sidebar).
internal/desktop/index.go:45
IndexDirdiscards every error while reading an account's workspace directory. If the desktop app's index exists but that directory is unreadable, this falls through toErrNoIndex, and callers report a harmless missing app instead of the real permission/storage failure. Return the read error so the operation does not silently skip a configured local index.
workspaces, err := os.ReadDir(filepath.Join(root, account.Name()))
if err != nil {
continue
internal/sync/desktop.go:97
- An existing but empty index is a valid state (for example after the user removes the last conversation), but this early return treats it as unchanged and leaves the previous non-empty remote payload intact. Other devices will keep seeing stale sidebar entries, and there is no way to publish an empty index. Let the empty payload continue through hashing and upload; retain the separate no-index case for a missing directory.
if len(snapshots) == 0 {
result.Unchanged = true
return result, nil
}
internal/sync/desktop.go:284
- All errors from resolving or scanning the desktop index are converted into an empty archive set, so
--skip-archivedsilently falls back to uploading every transcript. For example, an ambiguous or unreadable index causesPushto upload archived bodies before the laterPushDesktopcall reports the index error. Propagate lookup failures (while still treatingErrNoIndexas no labels) instead of treating every failure as an empty set.
dir, err := s.desktopIndexDir()
if err != nil {
return nil
- Files reviewed: 21/21 changed files
- Comments generated: 5
- Review effort level: Lite
| path := filepath.Join(dir, name) | ||
| if err := in.Record.Save(path); err != nil { |
| if err != nil { | ||
| return err | ||
| } | ||
| return os.WriteFile(path, data, 0o600) |
| if err := s.storage.Upload(ctx, config.DesktopRemoteKey+".age", encrypted); err != nil { | ||
| return nil, fmt.Errorf("failed to upload desktop index: %w", err) |
| id, ok := sessionIDFromPath(relPath) | ||
| return ok && archived[id] |
| // "not there" (empty result) from "cannot reach storage" (error) without | ||
| // relying on a provider-specific not-found error. | ||
| func (s *Syncer) remoteKeyExists(ctx context.Context, key string) (bool, error) { | ||
| objects, err := s.storage.List(ctx, key) |
|
Thanks for this — syncing the desktop sidebar index is a real gap, and the design notes made the intent easy to follow. Holding it for now, for a specific reason rather than a general one: five review threads are still unresolved, and three of them are data-loss paths with one write-escape among them. Summarising so they're in one place:
Separately, worth knowing for context: there are three overlapping desktop-sync PRs open right now (#77, #84, #86) touching the same core files. Yours is the most independent of the three — it only meets them at Also flagging that #82 merged earlier today and reworked content path mapping in Happy to re-review as soon as those threads are addressed. The symlink write is the blocker; the rest are ordinary review follow-ups. |
The problem
The desktop app doesn't render its sidebar from
~/.claude/projects. It keeps a separate index of pointer records in its own support directory, one per conversation, each naming a transcript through acliSessionIdfield.The effect: after a sync, conversations are resumable with
claude --resumebut invisible in the desktop app. On the machine that prompted this, 131 transcripts sat on disk while the sidebar showed 23 entries, none of them synced.What this adds
pull --desktop— restores sidebar entries alongside a normal pulldesktop pull/desktop push— move the index alone, mirroringmcp pull/mcp pushpush --skip-archived— omits archived conversations' transcript bodies while still syncing their archived stateDefault behaviour is unchanged —
pullwith no flag touches only~/.claude.Design notes
Records are transported, not reconstructed. The schema is the app's and gains fields across versions, so records are carried verbatim in a field map; only machine-local fields are rewritten on arrival.
Dedupe keys on
cliSessionId. The record's ownsessionIdis minted per device, so matching on it would append a duplicate row every pull.The index directory is discovered locally, never taken from remote data — the
<account>/<workspace>segments differ between machines.Archive state is tri-state. A machine without the desktop app knows nothing about a session, which differs from knowing it is active; a bool would let a CLI-only push un-archive conversations everywhere.
Conflicts are last-writer-wins, using the record's mtime as the observation time — the app rewrites a record when a session is archived, so no field had to be added to a schema I don't own.
Compatibility
The index uploads under an
_external/key, whichbuildRemoteMapalready skips, so it's invisible to the file-sync path including in older clients. No remote format migration needed.Testing
25 new tests. Each of the four commits builds and passes
go test ./...independently. Verified end to end across two machines.Update — a case found in real use
Running
desktop pullon a second machine before pulling its conversations produced sidebar rows that opened onto "Session not found on disk": the index arrived ahead of the transcripts it points at. The records were correct; the conversations behind them were simply still on the other machine.desktop pullnow writes records only for sessions with a local transcript, and reports the rest with the remedy attached:pull --desktopwas never affected — it pulls conversations before hydrating. The check readsclaudeDir, which the syncer already holds, sointernal/desktopkeeps its independence from the~/.claudelayout.Open questions
userDataconvention and are marked untested in the README. Happy to narrow to macOS-only.SECURITY-AUDIT.mdgains an L6 entry for the new write scope. Happy to drop that file if you'd rather own it.🤖 Generated with Claude Code