Skip to content

feat: sync the Claude desktop app's sidebar index - #84

Open
d-jiao wants to merge 8 commits into
tawanorg:mainfrom
d-jiao:feat/desktop-sidebar-sync
Open

d-jiao wants to merge 8 commits into
tawanorg:mainfrom
d-jiao:feat/desktop-sidebar-sync

Conversation

@d-jiao

@d-jiao d-jiao commented Aug 21, 2026 •

Copy link
Copy Markdown

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 a cliSessionId field.

The effect: after a sync, conversations are resumable with claude --resume but 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 pull
  • desktop pull / desktop push — move the index alone, mirroring mcp pull / mcp push
  • push --skip-archived — omits archived conversations' transcript bodies while still syncing their archived state
  • Archive state travels with every push; it exists only in this index, as no transcript records it

Default behaviour is unchanged — pull with 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 own sessionId is 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, which buildRemoteMap already 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 pull on 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 pull now writes records only for sessions with a local transcript, and reports the rest with the remedy attached:

✓ Desktop sidebar: 84 added, 0 archive states updated
  21 skipped — no transcript on this machine; run 'claude-sync pull' first

pull --desktop was never affected — it pulls conversations before hydrating. The check reads claudeDir, which the syncer already holds, so internal/desktop keeps its independence from the ~/.claude layout.

Open questions

  • Only macOS is verified. Linux and Windows follow Electron's userData convention and are marked untested in the README. Happy to narrow to macOS-only.
  • This depends on a schema reverse-engineered from 23 sample records. If you'd rather not couple the tool to app internals that can change without notice, that's a fair call and better heard now.
  • SECURITY-AUDIT.md gains an L6 entry for the new write scope. Happy to drop that file if you'd rather own it.

🤖 Generated with Claude Code

d-jiao and others added 4 commits August 20, 2026 21:33
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>
Copilot AI lite review requested due to automatic review settings August 21, 2026 01:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by cliSessionId and archive-state reconciliation.
  • Add CLI wiring: claude-sync desktop {push,pull} and claude-sync pull --desktop; always push the desktop index during push.
  • Add push --skip-archived to 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.

Comment thread internal/desktop/apply.go
Comment thread internal/sync/desktop.go Outdated
Comment thread internal/sync/desktop.go Outdated
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>
Copilot AI review requested due to automatic review settings August 22, 2026 04:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • PullDesktop unconditionally 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

  • SessionsRoot returns ErrNoHomeDir immediately when os.UserHomeDir() fails. On Windows, APPDATA is 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 --desktop is used (or via desktop 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

  • PullDesktop treats 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 set NoRemote when 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

Comment thread internal/sync/sync.go
d-jiao and others added 3 commits September 15, 2026 10:23
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>
Copilot AI review requested due to automatic review settings September 15, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 --desktop does not reach this block on the first pull when ~/.claude already contains files: the earlier first-pull branch returns handleFirstPullWithExistingFiles, 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 --force is 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 pull contents, but the implementation deliberately restores it only for pull --desktop (and push always carries it). This contradicts the documented default behavior and can make users expect a plain pull to hydrate the app. Clarify that pull --desktop is 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

  • IndexDir discards every error while reading an account's workspace directory. If the desktop app's index exists but that directory is unreadable, this falls through to ErrNoIndex, 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-archived silently falls back to uploading every transcript. For example, an ambiguous or unreadable index causes Push to upload archived bodies before the later PushDesktop call reports the index error. Propagate lookup failures (while still treating ErrNoIndex as 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

Comment thread internal/desktop/apply.go
Comment on lines +91 to +92
path := filepath.Join(dir, name)
if err := in.Record.Save(path); err != nil {
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
Comment thread internal/sync/desktop.go
Comment on lines +132 to +133
if err := s.storage.Upload(ctx, config.DesktopRemoteKey+".age", encrypted); err != nil {
return nil, fmt.Errorf("failed to upload desktop index: %w", err)
Comment thread internal/sync/desktop.go
Comment on lines +314 to +315
id, ok := sessionIDFromPath(relPath)
return ok && archived[id]
Comment thread internal/sync/desktop.go
// "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)
@tawanorg

tawanorg commented Oct 6, 2026

Copy link
Copy Markdown
Owner

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:

  • internal/desktop/record.go:42 — Record.Save uses os.WriteFile, which follows an existing symlink at the destination. A remote-controlled sessionId can therefore point at a pre-existing local_*.json symlink and make desktop pull write outside the index directory. That contradicts the L6 audit note claiming writes are confined there. Wants a no-follow open (os.OpenFile with O_NOFOLLOW) or an outright refusal on symlink destinations. I'd fix this one first — it's the only finding here that is a security property rather than a correctness one.
  • internal/desktop/apply.go:92 — deduplication only checks cliSessionId. A remote record with a fresh cliSessionId whose sessionId matches another local record's filename reaches Save and silently overwrites that record. Since sessionId is remote-controlled, this is more than the sidebar-clutter residual risk documented at L6.
  • internal/sync/desktop.go:133 — PushDesktop uploads the whole local snapshot as the single remote object, so if a second machine pushes before pulling, it replaces the remote index and drops the first machine's entries from every later pull. The design defines last-writer-wins for archive labels, not for the whole index.
  • internal/sync/desktop.go:315 — pushExcluder is passed to DetectChanges, which builds localFiles after exclusion and treats any tracked path missing from it as a deletion. So once an archived transcript has been uploaded and tracked, a later push --skip-archived schedules it for DeleteBatch and removes it remotely, rather than just skipping the upload.
  • internal/sync/desktop.go:338 — remoteKeyExists passes a full object key as a List prefix, but the WebDAV adapter appends / to any non-empty prefix (internal/storage/webdav/webdav.go:198-204), so the index is looked up as a directory, reported absent, and desktop pull never downloads it on WebDAV.

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 cmd/claude-sync/main.go, internal/sync/state.go and internal/sync/sync.go — so it can proceed on its own track. The lineage question for the other two is in #91.

Also flagging that #82 merged earlier today and reworked content path mapping in internal/sync/paths.go, so a rebase may be worth doing before your next push.

Happy to re-review as soon as those threads are addressed. The symlink write is the blocker; the rest are ordinary review follow-ups.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants