From bb882dc7f3cef60d32ac66c1c0c2e56b0d0081f8 Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Thu, 20 Aug 2026 21:33:09 -0400 Subject: [PATCH 1/8] feat(desktop): add package for the desktop app session index The desktop app renders its sidebar from its own index of pointer records at claude-code-sessions///local_.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 --- internal/desktop/apply.go | 135 ++++++++++++++++++++++ internal/desktop/apply_test.go | 197 ++++++++++++++++++++++++++++++++ internal/desktop/index.go | 64 +++++++++++ internal/desktop/index_test.go | 70 ++++++++++++ internal/desktop/record.go | 154 +++++++++++++++++++++++++ internal/desktop/record_test.go | 117 +++++++++++++++++++ internal/desktop/root.go | 47 ++++++++ internal/desktop/root_test.go | 47 ++++++++ internal/desktop/scan.go | 80 +++++++++++++ internal/desktop/scan_test.go | 67 +++++++++++ 10 files changed, 978 insertions(+) create mode 100644 internal/desktop/apply.go create mode 100644 internal/desktop/apply_test.go create mode 100644 internal/desktop/index.go create mode 100644 internal/desktop/index_test.go create mode 100644 internal/desktop/record.go create mode 100644 internal/desktop/record_test.go create mode 100644 internal/desktop/root.go create mode 100644 internal/desktop/root_test.go create mode 100644 internal/desktop/scan.go create mode 100644 internal/desktop/scan_test.go diff --git a/internal/desktop/apply.go b/internal/desktop/apply.go new file mode 100644 index 0000000..1f530b9 --- /dev/null +++ b/internal/desktop/apply.go @@ -0,0 +1,135 @@ +package desktop + +import ( + "os" + "path/filepath" + "time" +) + +// Incoming is an index record from the remote together with the moment the +// machine that pushed it last observed that record. ObservedAt is what makes +// archive reconciliation last-writer-wins; it is carried alongside the record +// rather than inside it because the record schema belongs to the desktop app +// and must not gain fields this tool invented. +type Incoming struct { + Record *Record + ObservedAt time.Time +} + +// Result summarises what Apply changed. +type Result struct { + Written int + Updated int + Skipped int +} + +// existingSessionIDs maps cliSessionId to the record file already representing +// it, so Apply can tell which sessions the local sidebar already knows about. +func existingSessionIDs(dir string) (map[string]string, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + known := make(map[string]string, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + // A record the app wrote in a format we cannot parse is not a + // reason to abort a pull; treat it as opaque and move on. + continue + } + if id := rec.CLISessionID(); id != "" { + known[id] = path + } + } + return known, nil +} + +// Apply writes index records into dir for sessions the local sidebar does not +// already know about. +// +// Dedupe keys on cliSessionId, never on the record's own sessionId: sessionId +// is minted per machine, so the same conversation carries a different one on +// every device and matching on it would append a duplicate sidebar row on each +// pull. +func Apply(dir string, incoming []Incoming) (Result, error) { + known, err := existingSessionIDs(dir) + if err != nil { + return Result{}, err + } + + var result Result + for _, in := range incoming { + if in.Record == nil { + result.Skipped++ + continue + } + id := in.Record.CLISessionID() + if id == "" { + result.Skipped++ + continue + } + + if path, exists := known[id]; exists { + updated, err := reconcileArchive(path, in) + if err != nil { + return result, err + } + if updated { + result.Updated++ + } else { + result.Skipped++ + } + continue + } + + name, err := in.Record.fileName() + if err != nil { + result.Skipped++ + continue + } + path := filepath.Join(dir, name) + if err := in.Record.Save(path); err != nil { + return result, err + } + known[id] = path + result.Written++ + } + return result, nil +} + +// reconcileArchive settles the archive state of a session the local sidebar +// already knows about, on a last-writer-wins basis. +// +// The local record's mtime stands in for "when this machine last decided": the +// desktop app rewrites the record when a session is archived or unarchived, so +// no field needs to be added to an app-owned schema to track it. +func reconcileArchive(path string, in Incoming) (bool, error) { + status := in.Record.ArchiveStatus() + if status == ArchiveUnknown { + // The pushing machine had no desktop index and so expressed no + // decision. Silence must never overwrite a known local state. + return false, nil + } + + info, err := os.Stat(path) + if err != nil { + return false, err + } + if !in.ObservedAt.After(info.ModTime()) { + return false, nil + } + + local, err := LoadRecord(path) + if err != nil { + return false, nil + } + if local.ArchiveStatus() == status { + return false, nil + } + local.setArchiveStatus(status) + if err := local.Save(path); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/desktop/apply_test.go b/internal/desktop/apply_test.go new file mode 100644 index 0000000..404d731 --- /dev/null +++ b/internal/desktop/apply_test.go @@ -0,0 +1,197 @@ +package desktop + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeRecord(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func countRecords(t *testing.T, dir string) int { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + t.Fatal(err) + } + return len(matches) +} + +// A record's own sessionId is machine-local, so the same conversation carries a +// different sessionId on every device. Matching on it would add a duplicate +// sidebar row for every session on every pull. cliSessionId is the stable +// cross-machine identity and is what dedupe must key on. +func TestApplySkipsSessionsThatAlreadyHaveARecord(t *testing.T) { + dir := t.TempDir() + writeRecord(t, dir, "local_already-here.json", + `{"sessionId":"local_already-here","cliSessionId":"abc-123","isArchived":false}`) + + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": "local_from-other-machine", + "cliSessionId": "abc-123", + "isArchived": false, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Written != 0 { + t.Errorf("Written = %d, want 0 (session already had a record)", result.Written) + } + if result.Updated != 0 { + t.Errorf("Updated = %d, want 0 (nothing newer to apply)", result.Updated) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1 (duplicate created)", n) + } +} + +// A session with no local record is the case worth acting on: write it. +func TestApplyWritesRecordsForUnknownSessions(t *testing.T) { + dir := t.TempDir() + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": "local_incoming", + "cliSessionId": "brand-new", + "isArchived": false, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Written != 1 { + t.Errorf("Written = %d, want 1", result.Written) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1", n) + } +} + +// sessionId arrives from the remote and is used to name the file on disk, which +// makes it attacker-influenced input to a filesystem write. A traversing value +// must never escape the index directory. +func TestApplyRejectsTraversingSessionID(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "index") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + for _, hostile := range []string{"../escaped", "../../escaped", "nested/escaped"} { + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": hostile, + "cliSessionId": "session-" + hostile, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err == nil && result.Written != 0 { + t.Errorf("sessionId %q was written; want refusal", hostile) + } + } + + escaped, err := filepath.Glob(filepath.Join(parent, "*.json")) + if err != nil { + t.Fatal(err) + } + if len(escaped) != 0 { + t.Errorf("records escaped the index directory: %v", escaped) + } +} + +func archiveStatusOnDisk(t *testing.T, path string) ArchiveStatus { + t.Helper() + rec, err := LoadRecord(path) + if err != nil { + t.Fatal(err) + } + return rec.ArchiveStatus() +} + +// Archiving is a deliberate user action on whichever machine performed it, so +// the more recent decision wins. Here the remote archived a session the local +// sidebar still shows as active. +func TestApplyAdoptsRemoteArchiveStatusWhenRemoteIsNewer(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":false}`) + stale := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + + incoming := Incoming{ + Record: &Record{fields: map[string]any{ + "sessionId": "local_remote", "cliSessionId": "abc", "isArchived": true, + }}, + ObservedAt: time.Now(), + } + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Updated != 1 { + t.Errorf("Updated = %d, want 1", result.Updated) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived", got) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1", n) + } +} + +// The mirror case: a local archive decision made after the remote observation +// must not be undone by an older remote label. +func TestApplyKeepsLocalArchiveStatusWhenLocalIsNewer(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":true}`) + + incoming := Incoming{ + Record: &Record{fields: map[string]any{ + "sessionId": "local_remote", "cliSessionId": "abc", "isArchived": false, + }}, + ObservedAt: time.Now().Add(-2 * time.Hour), + } + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Updated != 0 { + t.Errorf("Updated = %d, want 0 (local decision is newer)", result.Updated) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived (local state was overwritten)", got) + } +} + +// An incoming label of unknown provenance carries no decision at all. It must +// never overwrite a known local state, however recent it appears. +func TestApplyNeverLetsUnknownOverwriteKnownStatus(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":true}`) + + incoming := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_r", "cliSessionId": "abc"}}, + ObservedAt: time.Now().Add(time.Hour), + } + + if _, err := Apply(dir, []Incoming{incoming}); err != nil { + t.Fatalf("Apply() error: %v", err) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived (unknown label clobbered it)", got) + } +} diff --git a/internal/desktop/index.go b/internal/desktop/index.go new file mode 100644 index 0000000..92d278c --- /dev/null +++ b/internal/desktop/index.go @@ -0,0 +1,64 @@ +// Package desktop reads and writes the Claude desktop app's session index. +// +// The desktop app does not render its sidebar from ~/.claude/projects. It keeps +// a separate index of small pointer records at +// claude-code-sessions///local_.json, each naming a +// transcript through its cliSessionId field. Syncing ~/.claude alone therefore +// restores conversations for `claude --resume` while leaving the sidebar empty. +package desktop + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// ErrNoIndex reports that no desktop session index exists. A machine that has +// never opened the desktop app is an ordinary skip, not a sync failure. +var ErrNoIndex = errors.New("no desktop session index found") + +// ErrAmbiguousIndex reports that several / leaves exist and +// none can be chosen safely. +var ErrAmbiguousIndex = errors.New("multiple desktop session index directories") + +// IndexDir returns the / leaf directory holding the index +// records under root. +func IndexDir(root string) (string, error) { + var found []string + + accounts, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w under %s", ErrNoIndex, root) + } + return "", err + } + for _, account := range accounts { + if !account.IsDir() { + continue + } + workspaces, err := os.ReadDir(filepath.Join(root, account.Name())) + if err != nil { + continue + } + for _, workspace := range workspaces { + if !workspace.IsDir() { + continue + } + found = append(found, filepath.Join(root, account.Name(), workspace.Name())) + } + } + + sort.Strings(found) + switch len(found) { + case 0: + return "", fmt.Errorf("%w under %s", ErrNoIndex, root) + case 1: + return found[0], nil + default: + return "", fmt.Errorf("%w under %s: %s", ErrAmbiguousIndex, root, strings.Join(found, ", ")) + } +} diff --git a/internal/desktop/index_test.go b/internal/desktop/index_test.go new file mode 100644 index 0000000..3893fdb --- /dev/null +++ b/internal/desktop/index_test.go @@ -0,0 +1,70 @@ +package desktop + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// The desktop index lives at claude-code-sessions///, and +// those two UUID segments differ between machines. Pull must discover the local +// pair rather than reusing the ones baked into synced records, or it writes +// into a directory the local app never reads. +func TestIndexDirFindsSingleLeaf(t *testing.T) { + root := t.TempDir() + want := filepath.Join(root, "6dfc58e6-account", "580d1a7a-workspace") + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + got, err := IndexDir(root) + if err != nil { + t.Fatalf("IndexDir() unexpected error: %v", err) + } + if got != want { + t.Errorf("IndexDir() = %q, want %q", got, want) + } +} + +// Silently picking one of several candidates would write records into a +// directory the running app may not read, producing a pull that reports success +// and changes nothing the user can see. Fail loudly and name the candidates. +func TestIndexDirRejectsAmbiguousLeaves(t *testing.T) { + root := t.TempDir() + for _, leaf := range []string{"acct-a/ws-one", "acct-b/ws-two"} { + if err := os.MkdirAll(filepath.Join(root, leaf), 0o755); err != nil { + t.Fatal(err) + } + } + + _, err := IndexDir(root) + if !errors.Is(err, ErrAmbiguousIndex) { + t.Fatalf("IndexDir() error = %v, want ErrAmbiguousIndex", err) + } + for _, want := range []string{"ws-one", "ws-two"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name candidate %q", err, want) + } + } +} + +// A machine that has never opened the desktop app has no index at all. That is +// an ordinary skip, not a failure, so callers need to distinguish it. +func TestIndexDirReportsMissingIndex(t *testing.T) { + if _, err := IndexDir(t.TempDir()); !errors.Is(err, ErrNoIndex) { + t.Fatalf("IndexDir() error = %v, want ErrNoIndex", err) + } +} + +// A CLI-only machine has no claude-code-sessions directory whatsoever. That is +// the same "nothing to do" condition as an empty index and must not surface as +// an opaque filesystem error that aborts an otherwise healthy pull. +func TestIndexDirTreatsMissingRootAsNoIndex(t *testing.T) { + missing := filepath.Join(t.TempDir(), "claude-code-sessions") + + if _, err := IndexDir(missing); !errors.Is(err, ErrNoIndex) { + t.Fatalf("IndexDir() error = %v, want ErrNoIndex", err) + } +} diff --git a/internal/desktop/record.go b/internal/desktop/record.go new file mode 100644 index 0000000..dc7537a --- /dev/null +++ b/internal/desktop/record.go @@ -0,0 +1,154 @@ +package desktop + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Record is one desktop sidebar index record. +// +// It is deliberately backed by a generic field map rather than a typed struct. +// The schema is owned by the desktop app and gains fields across versions; a +// struct would drop everything it has not been taught about each time a record +// is rewritten. Only the fields this tool reasons about are accessed by name. +type Record struct { + fields map[string]any +} + +// LoadRecord reads an index record from disk, preserving every field verbatim. +func LoadRecord(path string) (*Record, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("parse index record %s: %w", path, err) + } + return &Record{fields: fields}, nil +} + +// Save writes the record, carrying unknown fields through unchanged. Records +// hold conversation titles and working directories, so they are written with +// the same owner-only permissions the desktop app uses. +func (r *Record) Save(path string) error { + data, err := json.Marshal(r.fields) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// ArchiveStatus is the tri-state archive state of a session. +// +// Archive state lives only in the desktop index: no record type in a CLI +// transcript encodes it. A machine without the desktop app therefore has no +// information about a session, which is distinct from knowing it is active. +// ArchiveUnknown is the zero value so a status that was never populated can +// never be mistaken for "active" and un-archive the session elsewhere. +type ArchiveStatus int + +const ( + ArchiveUnknown ArchiveStatus = iota + ArchiveActive + ArchiveArchived +) + +func (s ArchiveStatus) String() string { + switch s { + case ArchiveActive: + return "active" + case ArchiveArchived: + return "archived" + default: + return "unknown" + } +} + +// ArchiveStatus reports whether the session is archived. A missing or +// non-boolean isArchived field yields ArchiveUnknown rather than a guess. +func (r *Record) ArchiveStatus() ArchiveStatus { + value, ok := r.fields["isArchived"] + if !ok { + return ArchiveUnknown + } + archived, ok := value.(bool) + if !ok { + return ArchiveUnknown + } + if archived { + return ArchiveArchived + } + return ArchiveActive +} + +// CLISessionID returns the transcript this record points at. It is the stable +// identity across machines; the record's own sessionId is machine-local and +// must never be used to match records between devices. +func (r *Record) CLISessionID() string { + id, _ := r.fields["cliSessionId"].(string) + return id +} + +// fileName returns the on-disk name for this record. +// +// sessionId reaches this tool from remote storage and is used to name a file, +// which makes it untrusted input to a filesystem write. Anything that is not a +// plain file name is refused rather than cleaned, so a malformed or hostile +// record can never place a file outside the index directory. +func (r *Record) fileName() (string, error) { + id, _ := r.fields["sessionId"].(string) + if id == "" { + id = "local_" + r.CLISessionID() + } + if id == "" || id == "." || id == ".." { + return "", fmt.Errorf("record has no usable session id") + } + if strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { + return "", fmt.Errorf("record session id %q is not a plain file name", id) + } + name := id + ".json" + if name != filepath.Base(name) { + return "", fmt.Errorf("record session id %q is not a plain file name", id) + } + return name, nil +} + +// setArchiveStatus records a known archive decision. ArchiveUnknown is ignored: +// it carries no decision and must not write a value into the record. +func (r *Record) setArchiveStatus(status ArchiveStatus) { + switch status { + case ArchiveArchived: + r.fields["isArchived"] = true + case ArchiveActive: + r.fields["isArchived"] = false + } +} + +// UnmarshalJSON stores every field verbatim so a record can be carried through +// a sync payload without losing anything this tool does not model. +func (r *Record) UnmarshalJSON(data []byte) error { + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + r.fields = fields + return nil +} + +// MarshalJSON writes the record back out exactly as it came in. +func (r *Record) MarshalJSON() ([]byte, error) { + if r.fields == nil { + return []byte("null"), nil + } + return json.Marshal(r.fields) +} + +// NewRecord builds a record from an already-decoded field map. It exists for +// callers outside this package that assemble records from a sync payload. +func NewRecord(fields map[string]any) *Record { + return &Record{fields: fields} +} diff --git a/internal/desktop/record_test.go b/internal/desktop/record_test.go new file mode 100644 index 0000000..665ad0a --- /dev/null +++ b/internal/desktop/record_test.go @@ -0,0 +1,117 @@ +package desktop + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The record schema belongs to the desktop app, is undocumented, and gains +// fields across app versions. Modelling it as a fixed struct would silently drop +// anything this tool has not been taught about, corrupting records on every +// round-trip. Carry unknown fields through untouched. +func TestRecordRoundTripPreservesUnknownFields(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "local_source.json") + original := `{"sessionId":"local_a","cliSessionId":"abc-123","isArchived":false,` + + `"fieldFromANewerApp":{"nested":[1,2]}}` + if err := os.WriteFile(src, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + rec, err := LoadRecord(src) + if err != nil { + t.Fatalf("LoadRecord() error: %v", err) + } + + dst := filepath.Join(dir, "local_dest.json") + if err := rec.Save(dst); err != nil { + t.Fatalf("Save() error: %v", err) + } + + var got map[string]any + data, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("saved record is not valid JSON: %v", err) + } + if _, ok := got["fieldFromANewerApp"]; !ok { + t.Errorf("unknown field dropped on round-trip; got keys %v", keysOf(got)) + } + if got["cliSessionId"] != "abc-123" { + t.Errorf("cliSessionId = %v, want abc-123", got["cliSessionId"]) + } +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// Archive state exists only in the desktop index; nothing in a CLI transcript +// encodes it. A machine that never opened the desktop app therefore knows +// nothing about a session's archive state, which is NOT the same as knowing it +// is active. Collapsing the two into a bool would let a CLI-only push report +// every session as active and un-archive them on every other machine. +func TestArchiveStatusDistinguishesUnknownFromActive(t *testing.T) { + cases := []struct { + name string + body string + want ArchiveStatus + }{ + {"explicitly archived", `{"isArchived":true}`, ArchiveArchived}, + {"explicitly active", `{"isArchived":false}`, ArchiveActive}, + {"field absent entirely", `{"cliSessionId":"x"}`, ArchiveUnknown}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "local_r.json") + if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { + t.Fatal(err) + } + rec, err := LoadRecord(path) + if err != nil { + t.Fatal(err) + } + if got := rec.ArchiveStatus(); got != tc.want { + t.Errorf("ArchiveStatus() = %v, want %v", got, tc.want) + } + }) + } +} + +// Records travel to and from remote storage inside a sync payload, so they must +// survive encoding/json in both directions with unknown fields intact. +func TestRecordSurvivesEncodingJSONRoundTrip(t *testing.T) { + src := []byte(`{"cliSessionId":"abc","isArchived":true,"unknownField":42}`) + + var rec Record + if err := json.Unmarshal(src, &rec); err != nil { + t.Fatalf("Unmarshal() error: %v", err) + } + if rec.CLISessionID() != "abc" { + t.Errorf("CLISessionID() = %q, want abc", rec.CLISessionID()) + } + if rec.ArchiveStatus() != ArchiveArchived { + t.Errorf("ArchiveStatus() = %v, want archived", rec.ArchiveStatus()) + } + + out, err := json.Marshal(&rec) + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatal(err) + } + if got["unknownField"] != float64(42) { + t.Errorf("unknownField = %v, want 42 (dropped in transit)", got["unknownField"]) + } +} diff --git a/internal/desktop/root.go b/internal/desktop/root.go new file mode 100644 index 0000000..0999825 --- /dev/null +++ b/internal/desktop/root.go @@ -0,0 +1,47 @@ +package desktop + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +// sessionsDirName is the directory the desktop app keeps its sidebar index in, +// below the app's Electron userData directory. +const sessionsDirName = "claude-code-sessions" + +// ErrNoHomeDir is returned when the user's home directory cannot be determined. +var ErrNoHomeDir = errors.New("could not determine home directory") + +// sessionsRoot resolves the index root for a platform. +// +// The darwin layout is confirmed against a real installation. The windows and +// linux layouts follow Electron's userData convention, which this app is built +// on, but have not been verified on those platforms. +func sessionsRoot(goos, home, appData string) (string, error) { + if home == "" && appData == "" { + return "", ErrNoHomeDir + } + switch goos { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude", sessionsDirName), nil + case "windows": + base := appData + if base == "" { + base = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(base, "Claude", sessionsDirName), nil + default: + return filepath.Join(home, ".config", "Claude", sessionsDirName), nil + } +} + +// 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")) +} diff --git a/internal/desktop/root_test.go b/internal/desktop/root_test.go new file mode 100644 index 0000000..09e4619 --- /dev/null +++ b/internal/desktop/root_test.go @@ -0,0 +1,47 @@ +package desktop + +import ( + "path/filepath" + "testing" +) + +// Only the darwin layout has been observed on a real machine. The windows and +// linux layouts follow Electron's userData convention, which the desktop app +// uses, but they are inferred rather than verified. +func TestSessionsRootPerPlatform(t *testing.T) { + const home = "/home/u" + cases := []struct { + goos string + appData string + want string + }{ + {"darwin", "", filepath.Join(home, "Library", "Application Support", "Claude", "claude-code-sessions")}, + {"linux", "", filepath.Join(home, ".config", "Claude", "claude-code-sessions")}, + {"windows", `C:\Users\u\AppData\Roaming`, filepath.Join(`C:\Users\u\AppData\Roaming`, "Claude", "claude-code-sessions")}, + } + + for _, tc := range cases { + t.Run(tc.goos, func(t *testing.T) { + got, err := sessionsRoot(tc.goos, home, tc.appData) + if err != nil { + t.Fatalf("sessionsRoot() error: %v", err) + } + if got != tc.want { + t.Errorf("sessionsRoot(%q) = %q, want %q", tc.goos, got, tc.want) + } + }) + } +} + +// APPDATA is the authoritative location on Windows; falling back to a guessed +// AppData\Roaming path is only correct when the variable is unset. +func TestSessionsRootFallsBackWhenAppDataUnset(t *testing.T) { + got, err := sessionsRoot("windows", "/home/u", "") + if err != nil { + t.Fatal(err) + } + want := filepath.Join("/home/u", "AppData", "Roaming", "Claude", "claude-code-sessions") + if got != want { + t.Errorf("sessionsRoot() = %q, want %q", got, want) + } +} diff --git a/internal/desktop/scan.go b/internal/desktop/scan.go new file mode 100644 index 0000000..cbe2151 --- /dev/null +++ b/internal/desktop/scan.go @@ -0,0 +1,80 @@ +package desktop + +import ( + "os" + "path/filepath" + "time" +) + +// Label is the archive state of one session together with when that state was +// last observed. ObservedAt comes from the record's modification time: the +// desktop app rewrites a record when the session is archived or unarchived, so +// the mtime tracks the decision without adding a field to an app-owned schema. +type Label struct { + Status ArchiveStatus + ObservedAt time.Time +} + +// Scan reads the local index and returns each session's archive label, keyed by +// cliSessionId — the identity that is stable across machines. +// +// A missing index is not an error: a machine that has never opened the desktop +// app simply contributes no labels, leaving push otherwise unchanged. +func Scan(dir string) (map[string]Label, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + + labels := make(map[string]Label, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + // An unparseable record is opaque, not fatal; it simply + // contributes no opinion about its session. + continue + } + id := rec.CLISessionID() + if id == "" { + continue + } + var observed time.Time + if info, err := os.Stat(path); err == nil { + observed = info.ModTime() + } + labels[id] = Label{Status: rec.ArchiveStatus(), ObservedAt: observed} + } + return labels, nil +} + +// Snapshot is one index record together with when it was last written. +type Snapshot struct { + Record *Record + ObservedAt time.Time +} + +// Snapshots loads every index record in dir. A missing index yields no +// snapshots rather than an error, so a CLI-only machine stays a normal machine. +func Snapshots(dir string) ([]Snapshot, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + + out := make([]Snapshot, 0, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + continue + } + if rec.CLISessionID() == "" { + continue + } + var observed time.Time + if info, err := os.Stat(path); err == nil { + observed = info.ModTime() + } + out = append(out, Snapshot{Record: rec, ObservedAt: observed}) + } + return out, nil +} diff --git a/internal/desktop/scan_test.go b/internal/desktop/scan_test.go new file mode 100644 index 0000000..5ecd322 --- /dev/null +++ b/internal/desktop/scan_test.go @@ -0,0 +1,67 @@ +package desktop + +import ( + "os" + "testing" + "time" +) + +// Push needs the archive state of every session the local sidebar knows about, +// keyed by the cross-machine identity, together with when that state was last +// touched so the other end can settle conflicts. +func TestScanLabelsSessionsByCLISessionID(t *testing.T) { + dir := t.TempDir() + writeRecord(t, dir, "local_one.json", + `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":true}`) + writeRecord(t, dir, "local_two.json", + `{"sessionId":"local_two","cliSessionId":"bbb","isArchived":false}`) + + labels, err := Scan(dir) + if err != nil { + t.Fatalf("Scan() error: %v", err) + } + if len(labels) != 2 { + t.Fatalf("Scan() returned %d labels, want 2", len(labels)) + } + if got := labels["aaa"].Status; got != ArchiveArchived { + t.Errorf("aaa status = %v, want archived", got) + } + if got := labels["bbb"].Status; got != ArchiveActive { + t.Errorf("bbb status = %v, want active", got) + } + if labels["aaa"].ObservedAt.IsZero() { + t.Error("label carries no observation time; conflicts cannot be settled") + } +} + +// A machine with no desktop index must report no labels rather than an error, +// so a CLI-only push stays a normal push. +func TestScanOnMissingDirReportsNoLabels(t *testing.T) { + labels, err := Scan(t.TempDir() + "/absent") + if err != nil { + t.Fatalf("Scan() error: %v", err) + } + if len(labels) != 0 { + t.Errorf("Scan() returned %d labels, want 0", len(labels)) + } +} + +// The observation time must track the record, not the moment of the scan, or +// every push would look like the freshest decision and always win. +func TestScanUsesRecordModificationTime(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_one.json", + `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":true}`) + stale := time.Now().Add(-72 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + + labels, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + if got := labels["aaa"].ObservedAt; got.After(time.Now().Add(-time.Hour)) { + t.Errorf("ObservedAt = %v, want the record's stale mtime", got) + } +} From 4039ce736ab4b0dfa2ea0e851ca63c516455a84d Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Thu, 20 Aug 2026 21:33:21 -0400 Subject: [PATCH 2/8] feat(sync): carry the desktop sidebar index through push and pull 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 --- internal/config/config.go | 5 + internal/sync/desktop.go | 279 +++++++++++++++++++++++++++++ internal/sync/desktop_test.go | 128 +++++++++++++ internal/sync/skiparchived_test.go | 90 ++++++++++ internal/sync/sync.go | 9 +- 5 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 internal/sync/desktop.go create mode 100644 internal/sync/desktop_test.go create mode 100644 internal/sync/skiparchived_test.go diff --git a/internal/config/config.go b/internal/config/config.go index bb0788e..0790f07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,11 @@ const ( // The _external/ prefix separates it from ~/.claude/-relative files. MCPRemoteKey = "_external/mcp-servers.json" + // DesktopRemoteKey is the remote storage key for the desktop app's sidebar + // session index. The index lives outside ~/.claude, so it travels as its own + // external object rather than as part of the synced path set. + DesktopRemoteKey = "_external/desktop-index.json" + // Sync scopes control which subset of ~/.claude is synced. // ScopeFull (default) syncs everything in SyncPaths; ScopeSessions limits // syncing to portable conversation data only. diff --git a/internal/sync/desktop.go b/internal/sync/desktop.go new file mode 100644 index 0000000..1151b29 --- /dev/null +++ b/internal/sync/desktop.go @@ -0,0 +1,279 @@ +package sync + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/tawanorg/claude-sync/internal/config" + "github.com/tawanorg/claude-sync/internal/desktop" +) + +// desktopPayloadVersion versions the wire format so an older client can +// recognise a payload it does not understand instead of misreading it. +const desktopPayloadVersion = 1 + +// desktopEntry carries one index record plus when 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. +type desktopEntry struct { + ObservedAt time.Time `json:"observed_at"` + Record *desktop.Record `json:"record"` +} + +type desktopPayload struct { + Version int `json:"version"` + Entries []desktopEntry `json:"entries"` +} + +// DesktopPushResult summarises a desktop index push. +type DesktopPushResult struct { + RecordsPushed int + Unchanged bool + NoIndex bool +} + +// DesktopPullResult summarises a desktop index pull. +type DesktopPullResult struct { + Written int + Updated int + Skipped int + NoIndex bool + NoRemote bool +} + +// desktopIndexDir resolves the index directory, preferring an explicit override. +func (s *Syncer) desktopIndexDir() (string, error) { + if s.desktopDir != "" { + return s.desktopDir, nil + } + root, err := desktop.SessionsRoot() + if err != nil { + return "", err + } + return desktop.IndexDir(root) +} + +// PushDesktop uploads the desktop sidebar index. +// +// The sidebar renders from this index, not from ~/.claude/projects, so syncing +// conversations without it leaves them resumable from the CLI but invisible in +// the app. A machine with no desktop app contributes nothing and is not an +// error. +func (s *Syncer) PushDesktop(ctx context.Context) (*DesktopPushResult, error) { + result := &DesktopPushResult{} + + dir, err := s.desktopIndexDir() + if err != nil { + if errors.Is(err, desktop.ErrNoIndex) { + result.NoIndex = true + return result, nil + } + return nil, err + } + + snapshots, err := desktop.Snapshots(dir) + if err != nil { + return nil, fmt.Errorf("failed to read desktop index: %w", err) + } + if len(snapshots) == 0 { + result.Unchanged = true + return result, nil + } + + payload := desktopPayload{Version: desktopPayloadVersion} + for _, snap := range snapshots { + payload.Entries = append(payload.Entries, desktopEntry{ + ObservedAt: snap.ObservedAt, + Record: snap.Record, + }) + } + // Stable ordering keeps the hash meaningful, so an unchanged index does not + // re-upload merely because the filesystem returned a different order. + sort.Slice(payload.Entries, func(i, j int) bool { + return payload.Entries[i].Record.CLISessionID() < payload.Entries[j].Record.CLISessionID() + }) + + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to serialize desktop index: %w", err) + } + + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + if existing := s.state.GetFile(config.DesktopRemoteKey); existing != nil && existing.Hash == hash { + result.Unchanged = true + return result, nil + } + + compressed, err := gzipCompress(data) + if err != nil { + return nil, fmt.Errorf("failed to compress desktop index: %w", err) + } + encrypted, err := s.encryptor.Encrypt(compressed) + if err != nil { + return nil, fmt.Errorf("failed to encrypt desktop index: %w", err) + } + if err := s.storage.Upload(ctx, config.DesktopRemoteKey+".age", encrypted); err != nil { + return nil, fmt.Errorf("failed to upload desktop index: %w", err) + } + + s.state.mu.Lock() + s.state.Files[config.DesktopRemoteKey] = &FileState{ + Path: config.DesktopRemoteKey, + Hash: hash, + Size: int64(len(data)), + ModTime: time.Now(), + Uploaded: time.Now(), + } + s.state.mu.Unlock() + + if err := s.state.Save(); err != nil { + return nil, fmt.Errorf("failed to save state: %w", err) + } + + result.RecordsPushed = len(payload.Entries) + return result, nil +} + +// PullDesktop applies the remote desktop index to this machine's sidebar. +// +// Records for sessions the sidebar has never seen are created; sessions it +// already knows about keep their local record and only have their archive state +// reconciled, last-writer-wins. +func (s *Syncer) PullDesktop(ctx context.Context) (*DesktopPullResult, error) { + result := &DesktopPullResult{} + + dir, err := s.desktopIndexDir() + if err != nil { + if errors.Is(err, desktop.ErrNoIndex) { + result.NoIndex = true + return result, nil + } + return nil, err + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + // No desktop app on this machine: nothing to hydrate. + result.NoIndex = true + return result, nil + } + + 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 + } + + 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) + } + + var payload desktopPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse desktop index: %w", err) + } + if payload.Version > desktopPayloadVersion { + return nil, fmt.Errorf("desktop index was written by a newer claude-sync (format v%d); upgrade to pull it", payload.Version) + } + + incoming := make([]desktop.Incoming, 0, len(payload.Entries)) + for _, entry := range payload.Entries { + if entry.Record == nil { + continue + } + incoming = append(incoming, desktop.Incoming{ + Record: entry.Record, + ObservedAt: entry.ObservedAt, + }) + } + + applied, err := desktop.Apply(dir, incoming) + if err != nil { + return nil, fmt.Errorf("failed to apply desktop index: %w", err) + } + result.Written = applied.Written + result.Updated = applied.Updated + result.Skipped = applied.Skipped + return result, nil +} + +// SetSkipArchived controls whether push omits archived sessions' transcript +// bodies. The archive labels themselves are always pushed, so the other machine +// still learns that a session was archived; only the bulk transcript is held +// back. Omitting the label too would let an archive decision made after the +// first sync never reach the other device. +func (s *Syncer) SetSkipArchived(skip bool) { + s.skipArchived = skip +} + +// sessionIDFromPath returns the session a transcript path belongs to. +// +// Transcripts live at projects//.jsonl exactly one +// level below a project directory. Anything else — history.jsonl, task files, +// nested artefacts — belongs to no single session and is never filtered. +func sessionIDFromPath(relPath string) (string, bool) { + parts := strings.Split(filepath.ToSlash(relPath), "/") + if len(parts) != 3 || parts[0] != "projects" { + return "", false + } + name := parts[2] + if !strings.HasSuffix(name, ".jsonl") { + return "", false + } + return strings.TrimSuffix(name, ".jsonl"), true +} + +// archivedSessionIDs reports which sessions the local desktop index marks +// archived. A machine without a desktop index knows of none. +func (s *Syncer) archivedSessionIDs() map[string]bool { + dir, err := s.desktopIndexDir() + if err != nil { + return nil + } + labels, err := desktop.Scan(dir) + if err != nil { + return nil + } + archived := make(map[string]bool) + for id, label := range labels { + if label.Status == desktop.ArchiveArchived { + archived[id] = true + } + } + return archived +} + +// pushExcluder composes the configured excludes with --skip-archived. It is +// used only by push: pull and status keep the plain exclude set so the flag +// cannot silently change what a download or a status report covers. +func (s *Syncer) pushExcluder() func(string) bool { + if !s.skipArchived { + return s.isExcluded + } + archived := s.archivedSessionIDs() + if len(archived) == 0 { + return s.isExcluded + } + return func(relPath string) bool { + if s.isExcluded(relPath) { + return true + } + id, ok := sessionIDFromPath(relPath) + return ok && archived[id] + } +} diff --git a/internal/sync/desktop_test.go b/internal/sync/desktop_test.go new file mode 100644 index 0000000..3c2989b --- /dev/null +++ b/internal/sync/desktop_test.go @@ -0,0 +1,128 @@ +package sync + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tawanorg/claude-sync/internal/desktop" +) + +func makeIndex(t *testing.T, records map[string]string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "acct", "workspace") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for name, body := range records { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +// The desktop sidebar renders from an index this tool did not previously sync. +// A conversation that reaches a second machine through ~/.claude alone is +// resumable from the CLI but invisible in the sidebar; carrying the index +// records is what closes that gap. +func TestDesktopPushPullRoundTrip(t *testing.T) { + ctx := context.Background() + + envA := setupTestEnv(t) + envA.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false,"quirk":7}`, + }) + + pushed, err := envA.syncer.PushDesktop(ctx) + if err != nil { + t.Fatalf("PushDesktop() error: %v", err) + } + if pushed.RecordsPushed != 1 { + t.Fatalf("RecordsPushed = %d, want 1", pushed.RecordsPushed) + } + + // A second machine sharing the same bucket and key, with an empty index. + stateB, err := LoadStateFromDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, t.TempDir(), true) + indexB := makeIndex(t, nil) + syncerB.desktopDir = indexB + + pulled, err := syncerB.PullDesktop(ctx) + if err != nil { + t.Fatalf("PullDesktop() error: %v", err) + } + if pulled.Written != 1 { + t.Errorf("Written = %d, want 1", pulled.Written) + } + + labels, err := desktop.Scan(indexB) + if err != nil { + t.Fatal(err) + } + if _, ok := labels["aaa"]; !ok { + t.Fatalf("session aaa did not reach the second machine; got %v", labels) + } + + // The app owns this schema: fields we do not model must survive the trip. + got, err := os.ReadFile(filepath.Join(indexB, "local_one.json")) + if err != nil { + t.Fatal(err) + } + if !containsKey(got, "quirk") { + t.Errorf("unknown field dropped in transit: %s", got) + } +} + +// Pushing an unchanged index must not re-upload, matching how MCP push behaves. +func TestDesktopPushSkipsUnchangedIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false}`, + }) + + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + again, err := env.syncer.PushDesktop(ctx) + if err != nil { + t.Fatal(err) + } + if !again.Unchanged { + t.Errorf("second PushDesktop() Unchanged = false, want true") + } +} + +// A machine with no desktop app must push and pull without error. +func TestDesktopOpsAreNoOpWithoutIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = filepath.Join(t.TempDir(), "absent") + + pushed, err := env.syncer.PushDesktop(ctx) + if err != nil { + t.Fatalf("PushDesktop() on missing index: %v", err) + } + if pushed.RecordsPushed != 0 { + t.Errorf("RecordsPushed = %d, want 0", pushed.RecordsPushed) + } + + if _, err := env.syncer.PullDesktop(ctx); err != nil { + t.Fatalf("PullDesktop() on missing index: %v", err) + } +} + +func containsKey(data []byte, key string) bool { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return false + } + _, ok := m[key] + return ok +} diff --git a/internal/sync/skiparchived_test.go b/internal/sync/skiparchived_test.go new file mode 100644 index 0000000..bf04b3d --- /dev/null +++ b/internal/sync/skiparchived_test.go @@ -0,0 +1,90 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSessionIDFromPath(t *testing.T) { + cases := []struct { + path string + want string + wantOK bool + }{ + {"projects/-Users-x-proj/abc-123.jsonl", "abc-123", true}, + {"projects/-Users-x-proj/nested/abc-123.jsonl", "", false}, + {"history.jsonl", "", false}, + {"projects/-Users-x-proj/notes.txt", "", false}, + {"tasks/abc.json", "", false}, + } + for _, tc := range cases { + got, ok := sessionIDFromPath(tc.path) + if ok != tc.wantOK || got != tc.want { + t.Errorf("sessionIDFromPath(%q) = (%q,%v), want (%q,%v)", tc.path, got, ok, tc.want, tc.wantOK) + } + } +} + +// --skip-archived exists to keep finished conversations out of the upload. The +// archived transcript body must stay local while an active one still syncs. +func TestPushSkipArchivedExcludesArchivedTranscripts(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + projDir := filepath.Join(env.claudeDir, "projects", "-Users-x-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + for _, id := range []string{"archived-one", "active-one"} { + if err := os.WriteFile(filepath.Join(projDir, id+".jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + } + + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"archived-one","isArchived":true}`, + "local_b.json": `{"sessionId":"local_b","cliSessionId":"active-one","isArchived":false}`, + }) + env.syncer.skipArchived = true + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + + uploaded := strings.Join(result.Uploaded, "\n") + if strings.Contains(uploaded, "archived-one") { + t.Errorf("archived transcript was uploaded despite --skip-archived:\n%s", uploaded) + } + if !strings.Contains(uploaded, "active-one") { + t.Errorf("active transcript was not uploaded:\n%s", uploaded) + } +} + +// Without the flag, nothing changes: archived transcripts still sync. +func TestPushWithoutSkipArchivedUploadsEverything(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + projDir := filepath.Join(env.claudeDir, "projects", "-Users-x-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "archived-one.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"archived-one","isArchived":true}`, + }) + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + if !strings.Contains(strings.Join(result.Uploaded, "\n"), "archived-one") { + t.Errorf("archived transcript was skipped without the flag: %v", result.Uploaded) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..e9a4ea1 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -52,6 +52,13 @@ type Syncer struct { onProgress ProgressFunc cfg *config.Config paths *PathMapper + + // desktopDir overrides the resolved desktop index directory. It is set in + // tests; in normal use it stays empty and the directory is discovered. + desktopDir string + + // skipArchived drops archived sessions' transcript bodies from push. + skipArchived bool } type SyncResult struct { @@ -179,7 +186,7 @@ func (s *Syncer) Push(ctx context.Context) (*SyncResult, error) { s.progress(ProgressEvent{Action: "scan", Path: "Detecting changes..."}) - changes, err := s.state.DetectChanges(s.claudeDir, s.syncPaths(), s.isExcluded) + changes, err := s.state.DetectChanges(s.claudeDir, s.syncPaths(), s.pushExcluder()) if err != nil { return nil, fmt.Errorf("failed to detect changes: %w", err) } From 05165b54906163589bff621ffbae6591f884e259 Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Thu, 20 Aug 2026 21:33:30 -0400 Subject: [PATCH 3/8] feat(cli): add desktop subcommands and --desktop/--skip-archived flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/claude-sync/desktop_test.go | 42 +++++++++++ cmd/claude-sync/main.go | 129 ++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 cmd/claude-sync/desktop_test.go diff --git a/cmd/claude-sync/desktop_test.go b/cmd/claude-sync/desktop_test.go new file mode 100644 index 0000000..e444a42 --- /dev/null +++ b/cmd/claude-sync/desktop_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "sort" + "testing" +) + +// Sidebar hydration is wired inside `pull`, so exercising it otherwise means +// running a full sync — downloading conversations just to fix a sidebar. MCP +// solved the same problem with standalone subcommands; desktop mirrors that so +// the index can be pushed or hydrated on its own. +func TestDesktopCmdExposesPushAndPull(t *testing.T) { + cmd := desktopCmd() + + if cmd.Use != "desktop" { + t.Errorf("Use = %q, want %q", cmd.Use, "desktop") + } + + found := map[string]bool{} + for _, sub := range cmd.Commands() { + found[sub.Name()] = true + } + for _, want := range []string{"push", "pull"} { + if !found[want] { + names := make([]string, 0, len(found)) + for n := range found { + names = append(names, n) + } + sort.Strings(names) + t.Errorf("desktop has no %q subcommand; got %v", want, names) + } + } +} + +// Every subcommand needs a one-line Short, or it renders blank in help output. +func TestDesktopSubcommandsAreDocumented(t *testing.T) { + for _, sub := range desktopCmd().Commands() { + if sub.Short == "" { + t.Errorf("subcommand %q has no Short description", sub.Name()) + } + } +} diff --git a/cmd/claude-sync/main.go b/cmd/claude-sync/main.go index 4f0d47f..90a9745 100644 --- a/cmd/claude-sync/main.go +++ b/cmd/claude-sync/main.go @@ -73,6 +73,7 @@ func main() { updateCmd(), changelogCmd(), mcpCmd(), + desktopCmd(), autoCmd(), pathsCmd(), ) @@ -1012,6 +1013,7 @@ func runWebDAVWizard(webdavURL, username, password, pathPrefix string) (*storage func pushCmd() *cobra.Command { var includeMCP bool + var skipArchived bool cmd := &cobra.Command{ Use: "push", @@ -1027,6 +1029,7 @@ func pushCmd() *cobra.Command { if err != nil { return err } + syncer.SetSkipArchived(skipArchived) if !quiet { syncer.SetProgressFunc(func(event sync.ProgressEvent) { @@ -1109,16 +1112,25 @@ func pushCmd() *cobra.Command { } } + // The sidebar index always travels with a push so archive state can + // reach other devices. Without it, an archive decision made here would + // never be visible anywhere else. + if err := runDesktopPush(ctx, syncer); err != nil { + return err + } + return nil }, } cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json") + cmd.Flags().BoolVar(&skipArchived, "skip-archived", false, "Do not upload transcripts of archived conversations (their archived state is still synced)") return cmd } func pullCmd() *cobra.Command { var dryRun, force, includeMCP, rebuildHistory bool + var desktopSync bool cmd := &cobra.Command{ Use: "pull", @@ -1247,6 +1259,14 @@ Examples: } } + // Hydrate the desktop sidebar, which reads its own index rather than + // ~/.claude/projects. Skipped on a dry run: it writes to disk. + if desktopSync && !dryRun { + if err := runDesktopPull(ctx, syncer); err != nil { + return err + } + } + // Rebuild prompt history from the freshly-pulled session files. if rebuildHistory && !dryRun { if err := runHistoryRebuild(); err != nil { @@ -1262,6 +1282,7 @@ Examples: cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files without confirmation") cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json") cmd.Flags().BoolVar(&rebuildHistory, "rebuild-history", false, "Rebuild ~/.claude/history.jsonl from session files after pulling") + cmd.Flags().BoolVar(&desktopSync, "desktop", false, "Also restore the Claude desktop app's sidebar entries for pulled conversations") return cmd } @@ -2734,6 +2755,57 @@ func printReleaseBody(body string) { // MCP sync commands +func desktopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "desktop", + Short: "Manage Claude desktop app sidebar sync", + Long: `Sync the Claude desktop app's sidebar session index. + +The desktop app renders its sidebar from its own index, not from +~/.claude/projects. Conversations carried by push/pull are therefore resumable +with 'claude --resume' but stay invisible in the app until that index travels +too. These subcommands move the index on its own, without syncing conversations.`, + } + cmd.AddCommand(desktopPushCmd(), desktopPullCmd()) + return cmd +} + +func desktopPushCmd() *cobra.Command { + return &cobra.Command{ + Use: "push", + Short: "Upload this device's sidebar index", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + syncer, err := sync.NewSyncer(cfg, quiet) + if err != nil { + return err + } + return runDesktopPush(context.Background(), syncer) + }, + } +} + +func desktopPullCmd() *cobra.Command { + return &cobra.Command{ + Use: "pull", + Short: "Restore sidebar entries from another device (leaves conversations untouched)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + syncer, err := sync.NewSyncer(cfg, quiet) + if err != nil { + return err + } + return runDesktopPull(context.Background(), syncer) + }, + } +} + func mcpCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", @@ -2932,6 +3004,63 @@ func mcpPullCmd() *cobra.Command { } } +// runDesktopPush uploads the desktop sidebar index alongside the file push. +// +// The sidebar renders from an index that lives outside ~/.claude, so a push +// carrying only transcripts leaves conversations resumable from the CLI but +// absent from the app on every other machine. +func runDesktopPush(ctx context.Context, syncer *sync.Syncer) error { + result, err := syncer.PushDesktop(ctx) + if err != nil { + return fmt.Errorf("desktop index push failed: %w", err) + } + if quiet { + return nil + } + switch { + case result.NoIndex: + // No desktop app on this machine; nothing worth reporting. + case result.Unchanged: + fmt.Printf("%s\u2713%s Desktop sidebar: no changes to push\n", colorGreen, colorReset) + default: + fmt.Printf("%s\u2713%s Desktop sidebar: %s%d records pushed%s\n", + colorGreen, colorReset, colorGreen, result.RecordsPushed, colorReset) + } + return nil +} + +// runDesktopPull applies the remote desktop index to this machine's sidebar. +func runDesktopPull(ctx context.Context, syncer *sync.Syncer) error { + result, err := syncer.PullDesktop(ctx) + if err != nil { + return fmt.Errorf("desktop index pull failed: %w", err) + } + if quiet { + return nil + } + switch { + case result.NoIndex: + fmt.Printf("%s!%s Desktop sidebar: no local index found (is the desktop app installed?)\n", colorYellow, colorReset) + case result.NoRemote: + fmt.Printf("%s!%s Desktop sidebar: nothing pushed from another device yet\n", colorYellow, colorReset) + default: + fmt.Printf("%s\u2713%s Desktop sidebar: %s%d added%s, %d archive %s updated\n", + colorGreen, colorReset, colorGreen, result.Written, colorReset, + result.Updated, pluralRecords(result.Updated)) + if result.Written > 0 || result.Updated > 0 { + fmt.Printf(" %sRestart Claude to see the changes.%s\n", colorDim, colorReset) + } + } + return nil +} + +func pluralRecords(n int) string { + if n == 1 { + return "state" + } + return "states" +} + func runMCPPush(ctx context.Context, syncer *sync.Syncer) error { result, err := syncer.PushMCP(ctx) if err != nil { From abee95555617fe218bd0314ed99008439a95839a Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Thu, 20 Aug 2026 21:33:38 -0400 Subject: [PATCH 4/8] docs: document desktop sidebar sync and its write scope 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 --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++ SECURITY-AUDIT.md | 28 ++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f56b49e..c341d5c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **Selective sync**: Choose `--scope sessions` to sync only conversation data (skip plugins/node_modules) - **Interactive wizard**: Arrow-key driven setup with validation - **Secure self-updating**: `claude-sync update` downloads and verifies SHA256 checksums +- **Desktop sidebar**: Restores the desktop app's conversation list, not just the CLI's session picker - **Simple CLI**: `push`, `pull`, `status`, `diff`, `conflicts` commands - **Compression**: Gzip compression before encryption for faster syncs - **Shell integration**: Optional shell hooks for automatic push/pull @@ -195,6 +196,9 @@ claude-sync pull | `~/.claude/settings.local.json` | Local settings | | `~/.claude/CLAUDE.md` | Global instructions | +Plus the desktop app's sidebar index, which lives **outside** `~/.claude` — see +[Desktop app sidebar](#desktop-app-sidebar). + ### Sync scope `init` asks whether to sync everything or just conversation data; you can also set it with `--scope`: @@ -249,6 +253,7 @@ claude-sync pull # Download remote changes from cloud storage claude-sync status # Show pending local changes claude-sync diff # Show differences between local and remote claude-sync conflicts # List and resolve conflicts +claude-sync desktop # Sync the desktop app's sidebar index claude-sync rebuild-history # Rebuild ~/.claude/history.jsonl from session files claude-sync reset # Reset configuration (forgot passphrase) claude-sync migrate # Convert legacy remote keys to portable path-mapped keys @@ -264,6 +269,7 @@ claude-sync pull # Normal pull (prompts if existing files) claude-sync pull --dry-run # Preview what would change claude-sync pull --force # Skip confirmation prompts claude-sync pull --rebuild-history # Also rebuild history.jsonl after pulling +claude-sync pull --desktop # Also restore desktop app sidebar entries ``` ### Rebuilding Prompt History @@ -281,6 +287,55 @@ claude-sync pull --rebuild-history # Rebuild automatically after a pull Every existing entry is preserved, recovered prompts are merged in and sorted by timestamp, and the previous file is kept as `history.jsonl.bak`. +### Desktop app sidebar + +The Claude desktop app does not render its sidebar from `~/.claude/projects`. It +keeps a separate index of small pointer records — one per conversation, each +naming a transcript — in its own support directory: + +| Platform | Location | Status | +|----------|----------|--------| +| macOS | `~/Library/Application Support/Claude/claude-code-sessions/` | Verified | +| Linux | `~/.config/Claude/claude-code-sessions/` | Untested | +| Windows | `%APPDATA%\Claude\claude-code-sessions\` | Untested | + +> The Linux and Windows locations follow Electron's `userData` convention, which +> the desktop app is built on, but have not been confirmed on those platforms. +> Reports welcome. On a machine where the index is not found, the desktop +> commands report that and change nothing. + +Syncing `~/.claude` alone therefore restores conversations for `claude --resume` +while leaving the desktop sidebar empty. `push` carries this index too, and +`pull --desktop` restores it: + +```bash +claude-sync pull --desktop # Pull conversations, then restore sidebar entries +claude-sync desktop pull # Restore sidebar entries only, no file sync +claude-sync desktop push # Upload this device's index only +``` + +Restart the desktop app afterwards — it reads the index at startup. + +Conversations that already have a sidebar entry are left untouched. Matching is +by transcript id rather than the app's per-device record id, so repeated pulls +never duplicate rows. + +**Archived conversations.** The archived flag exists only in this index; nothing +in a transcript records it. Archive state travels with every push, and the more +recent change wins when two devices disagree. To keep archived conversations' +transcripts out of the upload while still syncing the fact that they are +archived: + +```bash +claude-sync push --skip-archived +``` + +The label is always pushed even when the transcript is not, so a conversation +archived after its first sync still disappears from the other device's sidebar. + +A device that has never opened the desktop app has no index, contributes no +archive state, and is unaffected by any of this. + ### Init Options ```bash diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md index 6fd993e..c6d7914 100644 --- a/SECURITY-AUDIT.md +++ b/SECURITY-AUDIT.md @@ -9,14 +9,14 @@ ## Executive Summary -claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Five low findings cover file permissions and path handling. +claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Six low findings cover file permissions, path handling, and write scope. | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 5 | -| Low | 5 | +| Low | 6 | | Info | 5 | --- @@ -139,6 +139,29 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may --- +### L6 — Desktop Index Writes Outside `~/.claude` + +**File:** `internal/desktop/apply.go`, `internal/desktop/record.go` + +`pull --desktop` and `desktop pull` write index records into the desktop app's +support directory. This is the tool's first write target outside `~/.claude`. +Record filenames derive from a `sessionId` field carried in remote data, which +makes them attacker-influenced input to a filesystem write. + +**Mitigating factors:** Filenames are refused rather than sanitized — any value +containing a path separator or `..`, or which differs from its own +`filepath.Base`, is rejected and the record skipped (covered by +`TestApplyRejectsTraversingSessionID`). The destination directory is discovered +on the local machine and never taken from remote data. Both entry points are +opt-in; an ordinary `pull` does not write here. + +**Residual risk:** An actor with bucket write access can still add sidebar +entries naming arbitrary transcript ids. These surface as rows in the app's +sidebar. No content is executed and no file outside the index directory is +written, so the effect is limited to sidebar clutter. + +--- + ## Clean Areas | Area | Status | @@ -149,6 +172,7 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may | eval/exec injection | No dynamic code execution | | Encryption | Sound — age v1.3.1, X25519, Argon2id | | Input validation | CLI inputs properly handled | +| Index record filenames | Validated — traversal refused rather than sanitized (L6) | --- From e0c6280b7a2be8048f2d412612ca6cfb1028814d Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Fri, 21 Aug 2026 21:45:55 -0400 Subject: [PATCH 5/8] fix(desktop): skip sidebar entries whose transcript is absent locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//.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 --- README.md | 6 +++ cmd/claude-sync/main.go | 4 ++ internal/sync/desktop.go | 37 ++++++++++++++++-- internal/sync/desktop_test.go | 70 ++++++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c341d5c..7aa509e 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,12 @@ Conversations that already have a sidebar entry are left untouched. Matching is by transcript id rather than the app's per-device record id, so repeated pulls never duplicate rows. +A sidebar entry points at a transcript by id, so entries are only written for +conversations this machine actually has. Sessions whose transcript is absent are +skipped and reported — without that, the entry would appear in the sidebar and +open onto "Session not found on disk". Pull the conversations first, or use +`pull --desktop`, which pulls them before hydrating. + **Archived conversations.** The archived flag exists only in this index; nothing in a transcript records it. Archive state travels with every push, and the more recent change wins when two devices disagree. To keep archived conversations' diff --git a/cmd/claude-sync/main.go b/cmd/claude-sync/main.go index 90a9745..7c2dafd 100644 --- a/cmd/claude-sync/main.go +++ b/cmd/claude-sync/main.go @@ -3047,6 +3047,10 @@ func runDesktopPull(ctx context.Context, syncer *sync.Syncer) error { fmt.Printf("%s\u2713%s Desktop sidebar: %s%d added%s, %d archive %s updated\n", colorGreen, colorReset, colorGreen, result.Written, colorReset, result.Updated, pluralRecords(result.Updated)) + if result.MissingTranscript > 0 { + fmt.Printf(" %s%d skipped — no transcript on this machine; run 'claude-sync pull' first%s\n", + colorYellow, result.MissingTranscript, colorReset) + } if result.Written > 0 || result.Updated > 0 { fmt.Printf(" %sRestart Claude to see the changes.%s\n", colorDim, colorReset) } diff --git a/internal/sync/desktop.go b/internal/sync/desktop.go index 1151b29..f7174b4 100644 --- a/internal/sync/desktop.go +++ b/internal/sync/desktop.go @@ -44,9 +44,15 @@ type DesktopPushResult struct { // DesktopPullResult summarises a desktop index pull. type DesktopPullResult struct { - Written int - Updated int - Skipped int + Written int + Updated int + Skipped int + + // MissingTranscript counts sessions skipped because this machine has no + // transcript for them. Writing those records would produce sidebar rows + // that open onto "Session not found on disk". + MissingTranscript int + NoIndex bool NoRemote bool } @@ -191,11 +197,22 @@ func (s *Syncer) PullDesktop(ctx context.Context) (*DesktopPullResult, error) { return nil, fmt.Errorf("desktop index was written by a newer claude-sync (format v%d); upgrade to pull it", payload.Version) } + // A sidebar record points at a transcript by id. If this machine does not + // have that transcript, the row it creates opens onto "Session not found on + // disk" — the index is intact but the conversation behind it is elsewhere. + // Skip those and report them, so the remedy (pull the conversations first) + // is visible instead of appearing as a broken sidebar. + local := localTranscriptIDs(s.claudeDir) + incoming := make([]desktop.Incoming, 0, len(payload.Entries)) for _, entry := range payload.Entries { if entry.Record == nil { continue } + if id := entry.Record.CLISessionID(); id != "" && !local[id] { + result.MissingTranscript++ + continue + } incoming = append(incoming, desktop.Incoming{ Record: entry.Record, ObservedAt: entry.ObservedAt, @@ -277,3 +294,17 @@ func (s *Syncer) pushExcluder() func(string) bool { return ok && archived[id] } } + +// localTranscriptIDs returns the session ids this machine holds transcripts for. +// Transcripts live at projects//.jsonl. +func localTranscriptIDs(claudeDir string) map[string]bool { + ids := make(map[string]bool) + matches, err := filepath.Glob(filepath.Join(claudeDir, "projects", "*", "*.jsonl")) + if err != nil { + return ids + } + for _, path := range matches { + ids[strings.TrimSuffix(filepath.Base(path), ".jsonl")] = true + } + return ids +} diff --git a/internal/sync/desktop_test.go b/internal/sync/desktop_test.go index 3c2989b..99a3207 100644 --- a/internal/sync/desktop_test.go +++ b/internal/sync/desktop_test.go @@ -49,7 +49,18 @@ func TestDesktopPushPullRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, t.TempDir(), true) + // Machine B must hold the transcript, or the record is correctly skipped as + // one that would produce a dead sidebar row. + claudeB := t.TempDir() + projB := filepath.Join(claudeB, "projects", "-Users-x-proj") + if err := os.MkdirAll(projB, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projB, "aaa.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + + syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, claudeB, true) indexB := makeIndex(t, nil) syncerB.desktopDir = indexB @@ -126,3 +137,60 @@ func containsKey(data []byte, key string) bool { _, ok := m[key] return ok } + +// A sidebar entry points at a transcript by id. Writing one for a transcript +// this machine does not have produces a row that opens onto "Session not found +// on disk" — the index is fine, the conversation behind it simply is not here. +// Skip those and say so, rather than manufacturing dead rows. +func TestPullDesktopSkipsSessionsWithoutLocalTranscripts(t *testing.T) { + ctx := context.Background() + + envA := setupTestEnv(t) + envA.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_here.json": `{"sessionId":"local_here","cliSessionId":"present-one","isArchived":false}`, + "local_gone.json": `{"sessionId":"local_gone","cliSessionId":"absent-one","isArchived":false}`, + }) + if _, err := envA.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + + // Machine B has the transcript for only one of the two sessions. + stateB, err := LoadStateFromDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + claudeB := t.TempDir() + projB := filepath.Join(claudeB, "projects", "-Users-x-proj") + if err := os.MkdirAll(projB, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projB, "present-one.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + + syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, claudeB, true) + indexB := makeIndex(t, nil) + syncerB.desktopDir = indexB + + result, err := syncerB.PullDesktop(ctx) + if err != nil { + t.Fatalf("PullDesktop() error: %v", err) + } + if result.Written != 1 { + t.Errorf("Written = %d, want 1 (only the session whose transcript is present)", result.Written) + } + if result.MissingTranscript != 1 { + t.Errorf("MissingTranscript = %d, want 1", result.MissingTranscript) + } + + labels, err := desktop.Scan(indexB) + if err != nil { + t.Fatal(err) + } + if _, ok := labels["absent-one"]; ok { + t.Error("wrote a sidebar entry for a transcript this machine does not have") + } + if _, ok := labels["present-one"]; !ok { + t.Error("failed to write the entry whose transcript is present") + } +} From a91ff13daaed51eb4b775af3760447c0b4b57a67 Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Tue, 15 Sep 2026 10:23:31 -0400 Subject: [PATCH 6/8] fix(sync): stop change detection from deleting external remote objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/config/config.go | 6 +++ internal/sync/external_keys_test.go | 66 +++++++++++++++++++++++++++++ internal/sync/state.go | 8 ++++ internal/sync/sync.go | 2 +- 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 internal/sync/external_keys_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 0790f07..6d1b6f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,12 @@ const ( StateFile = "state.json" AgeKeyFile = "age-key.txt" + // ExternalKeyPrefix marks remote objects that are not files under ~/.claude + // (MCP servers, the desktop index). Both the pull mapper and the push + // change detector must treat these as out of band: they have no local + // path, so their absence from the local tree is never a deletion. + ExternalKeyPrefix = "_external/" + // MCPRemoteKey is the remote storage key for synced MCP server configs. // The _external/ prefix separates it from ~/.claude/-relative files. MCPRemoteKey = "_external/mcp-servers.json" diff --git a/internal/sync/external_keys_test.go b/internal/sync/external_keys_test.go new file mode 100644 index 0000000..cca6916 --- /dev/null +++ b/internal/sync/external_keys_test.go @@ -0,0 +1,66 @@ +package sync + +import ( + "context" + "strings" + "testing" + + "github.com/tawanorg/claude-sync/internal/config" +) + +// PushDesktop and PushMCP record their remote objects in state.Files so an +// unchanged payload is not re-uploaded. Those keys never correspond to a file +// under ~/.claude, so DetectChanges must not read their absence from the local +// tree as a deletion. Otherwise every push deletes the object and re-uploads +// it — and if the desktop app is absent when push runs, nothing re-uploads it +// and the remote index is simply gone. +func TestPushDoesNotDeleteExternalIndexOnSubsequentPush(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false}`, + }) + + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + key := config.DesktopRemoteKey + ".age" + if _, err := env.store.Download(ctx, key); err != nil { + t.Fatalf("precondition: desktop index not on remote after PushDesktop: %v", err) + } + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + for _, d := range result.Deleted { + if strings.HasPrefix(d, "_external/") { + t.Errorf("Push scheduled a delete of external key %q", d) + } + } + if _, err := env.store.Download(ctx, key); err != nil { + t.Errorf("desktop index was removed from the remote by an ordinary push: %v", err) + } + if env.syncer.state.GetFile(config.DesktopRemoteKey) == nil { + t.Error("desktop index entry was dropped from sync state by an ordinary push") + } +} + +// Same invariant at the unit level, and it covers the pre-existing MCP key too. +func TestDetectChangesIgnoresExternalKeys(t *testing.T) { + env := setupTestEnv(t) + env.syncer.state.mu.Lock() + env.syncer.state.Files[config.DesktopRemoteKey] = &FileState{Path: config.DesktopRemoteKey, Hash: "x"} + env.syncer.state.Files[config.MCPRemoteKey] = &FileState{Path: config.MCPRemoteKey, Hash: "y"} + env.syncer.state.mu.Unlock() + + changes, err := env.syncer.state.DetectChanges(env.claudeDir, env.syncer.syncPaths(), env.syncer.isExcluded) + if err != nil { + t.Fatal(err) + } + for _, c := range changes { + if strings.HasPrefix(c.Path, "_external/") { + t.Errorf("DetectChanges reported %s for external key %q", c.Action, c.Path) + } + } +} diff --git a/internal/sync/state.go b/internal/sync/state.go index f0ae2c8..1e9bafc 100644 --- a/internal/sync/state.go +++ b/internal/sync/state.go @@ -356,6 +356,14 @@ func (s *SyncState) DetectChanges(claudeDir string, syncPaths []string, excludeF s.mu.Unlock() for _, relPath := range knownPaths { + // External objects (MCP servers, the desktop index) are tracked in + // state so an unchanged payload is not re-uploaded, but they have no + // local file. Reading their absence as a deletion would delete the + // remote object on every push — and if the subsystem that owns it + // then has nothing to re-upload, the object is simply lost. + if strings.HasPrefix(relPath, config.ExternalKeyPrefix) { + continue + } if _, exists := localFiles[relPath]; !exists { changes = append(changes, FileChange{ Path: relPath, diff --git a/internal/sync/sync.go b/internal/sync/sync.go index e9a4ea1..a58a46a 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -648,7 +648,7 @@ func (s *Syncer) buildRemoteMap(remoteObjects []storage.ObjectInfo) (remoteFiles continue } // Skip external files (handled by MCP sync) - if strings.HasPrefix(localPath, "_external/") { + if strings.HasPrefix(localPath, config.ExternalKeyPrefix) { continue } // Skip metadata files (manifest, etc.) From 68fe9194baa3bd3972a8c00624d94bf869e4d270 Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Tue, 15 Sep 2026 10:23:31 -0400 Subject: [PATCH 7/8] fix(desktop): stamp pulled records with their observation time 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 --- internal/desktop/apply.go | 17 +++++++++++ internal/desktop/apply_test.go | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/internal/desktop/apply.go b/internal/desktop/apply.go index 1f530b9..fac3de2 100644 --- a/internal/desktop/apply.go +++ b/internal/desktop/apply.go @@ -92,6 +92,7 @@ func Apply(dir string, incoming []Incoming) (Result, error) { if err := in.Record.Save(path); err != nil { return result, err } + stampObservedAt(path, in.ObservedAt) known[id] = path result.Written++ } @@ -131,5 +132,21 @@ func reconcileArchive(path string, in Incoming) (bool, error) { if err := local.Save(path); err != nil { return false, err } + stampObservedAt(path, in.ObservedAt) return true, nil } + +// stampObservedAt sets the record's mtime to the moment the decision it +// carries was made. The mtime doubles as the observation time on the next +// push, so leaving a freshly written record at "now" would report another +// device's decision as though it were made here just now — an inflated time +// that could then beat a genuinely later change on the originating device. +// A zero ObservedAt carries no timing information and leaves the mtime alone. +func stampObservedAt(path string, observed time.Time) { + if observed.IsZero() { + return + } + // Best effort: a failure here degrades to the previous behaviour rather + // than failing a pull that has already written the record correctly. + _ = os.Chtimes(path, observed, observed) +} diff --git a/internal/desktop/apply_test.go b/internal/desktop/apply_test.go index 404d731..58ab26b 100644 --- a/internal/desktop/apply_test.go +++ b/internal/desktop/apply_test.go @@ -195,3 +195,59 @@ func TestApplyNeverLetsUnknownOverwriteKnownStatus(t *testing.T) { t.Errorf("on-disk status = %v, want archived (unknown label clobbered it)", got) } } + +// A record's mtime is its observation time on the next push. If Apply leaves +// a newly written record stamped "now", this machine later reports the other +// device's decision as though it were made here, just now — and that inflated +// time can beat a genuinely later change back on the originating device. +func TestApplyStampsNewRecordWithObservedAt(t *testing.T) { + dir := t.TempDir() + observed := time.Now().Add(-48 * time.Hour).Truncate(time.Second) + in := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_a", "cliSessionId": "abc", "isArchived": true}}, + ObservedAt: observed, + } + + if _, err := Apply(dir, []Incoming{in}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "local_a.json")) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(observed) { + t.Errorf("record mtime = %v, want the incoming ObservedAt %v", info.ModTime(), observed) + } +} + +// The same holds when an existing record adopts a remote archive decision: +// the record should carry the time of that decision, not the time of the pull. +func TestApplyStampsReconciledRecordWithObservedAt(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":false}`) + stale := time.Now().Add(-72 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + observed := time.Now().Add(-24 * time.Hour).Truncate(time.Second) + in := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_r", "cliSessionId": "abc", "isArchived": true}}, + ObservedAt: observed, + } + + res, err := Apply(dir, []Incoming{in}) + if err != nil { + t.Fatal(err) + } + if res.Updated != 1 { + t.Fatalf("Updated = %d, want 1", res.Updated) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(observed) { + t.Errorf("reconciled record mtime = %v, want ObservedAt %v", info.ModTime(), observed) + } +} From b7901603c0d0d9c4f9df70f4f4487dba4b48f041 Mon Sep 17 00:00:00 2001 From: "d.jiao" Date: Tue, 15 Sep 2026 10:23:31 -0400 Subject: [PATCH 8/8] fix(desktop): surface storage and filesystem failures instead of masking 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 --- internal/sync/desktop.go | 48 +++++++++++++++++-- internal/sync/desktop_test.go | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/internal/sync/desktop.go b/internal/sync/desktop.go index f7174b4..94930bb 100644 --- a/internal/sync/desktop.go +++ b/internal/sync/desktop.go @@ -167,18 +167,39 @@ func (s *Syncer) PullDesktop(ctx context.Context) (*DesktopPullResult, error) { } return nil, err } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + info, err := os.Stat(dir) + switch { + case errors.Is(err, os.ErrNotExist): // No desktop app on this machine: nothing to hydrate. result.NoIndex = true return result, nil - } - - encrypted, err := s.storage.Download(ctx, config.DesktopRemoteKey+".age") + case err != nil: + // Anything else — a permission problem, a file where a directory + // should be — is a real failure on a machine that may well have the + // app installed. Do not disguise it as "no desktop app". + return nil, fmt.Errorf("desktop index directory: %w", err) + case !info.IsDir(): + return nil, fmt.Errorf("desktop index path is not a directory: %s", dir) + } + + // The storage layer has no not-found sentinel across providers, so a bare + // Download error cannot tell "never pushed" apart from "unreachable" or + // "forbidden". Listing the exact key first 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. + remoteKey := config.DesktopRemoteKey + ".age" + present, err := s.remoteKeyExists(ctx, remoteKey) if err != nil { - // Nothing has been pushed yet. That is an empty result, not a failure. + return nil, fmt.Errorf("failed to check remote desktop index: %w", err) + } + if !present { result.NoRemote = true return result, nil } + encrypted, err := s.storage.Download(ctx, remoteKey) + if err != nil { + return nil, fmt.Errorf("failed to download desktop index: %w", err) + } compressed, err := s.encryptor.Decrypt(encrypted) if err != nil { @@ -308,3 +329,20 @@ func localTranscriptIDs(claudeDir string) map[string]bool { } return ids } + +// remoteKeyExists reports whether key is present on the remote, using a +// prefix listing on the exact key. Unlike Download, a listing distinguishes +// "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) + if err != nil { + return false, err + } + for _, obj := range objects { + if obj.Key == key { + return true, nil + } + } + return false, nil +} diff --git a/internal/sync/desktop_test.go b/internal/sync/desktop_test.go index 99a3207..7832723 100644 --- a/internal/sync/desktop_test.go +++ b/internal/sync/desktop_test.go @@ -3,11 +3,13 @@ package sync import ( "context" "encoding/json" + "errors" "os" "path/filepath" "testing" "github.com/tawanorg/claude-sync/internal/desktop" + "github.com/tawanorg/claude-sync/internal/storage" ) func makeIndex(t *testing.T, records map[string]string) string { @@ -194,3 +196,91 @@ func TestPullDesktopSkipsSessionsWithoutLocalTranscripts(t *testing.T) { t.Error("failed to write the entry whose transcript is present") } } + +// failingStorage wraps the mock so a test can inject the kinds of failure a +// real bucket produces — an unreachable endpoint, a rejected credential — as +// distinct from the object simply not existing. +type failingStorage struct { + *mockStorage + listErr error + downloadErr error +} + +func (f *failingStorage) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.mockStorage.List(ctx, prefix) +} + +func (f *failingStorage) Download(ctx context.Context, key string) ([]byte, error) { + if f.downloadErr != nil { + return nil, f.downloadErr + } + return f.mockStorage.Download(ctx, key) +} + +// "Nothing pushed yet" is a specific, benign condition. A storage that cannot +// be reached, or that rejects the credentials, is neither — reporting it as +// "nothing pushed yet" sends the user to check the other machine when the +// problem is in front of them. +func TestPullDesktopDistinguishesStorageFailureFromAbsence(t *testing.T) { + ctx := context.Background() + + t.Run("key genuinely absent is NoRemote, not an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, nil) + res, err := env.syncer.PullDesktop(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.NoRemote { + t.Error("NoRemote = false, want true when nothing has been pushed") + } + }) + + t.Run("listing failure surfaces as an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, nil) + env.syncer.storage = &failingStorage{mockStorage: env.store, listErr: errors.New("dial tcp: connection refused")} + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoRemote=%v; want the storage failure surfaced", res.NoRemote) + } + }) + + t.Run("download failure of an existing key surfaces as an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"aaa","isArchived":false}`, + }) + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + env.syncer.storage = &failingStorage{mockStorage: env.store, downloadErr: errors.New("403 Forbidden")} + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoRemote=%v; want the download failure surfaced", res.NoRemote) + } + }) +} + +// A missing index directory is the ordinary "no desktop app here" case. Any +// other stat failure — a permission problem, a file where a directory should +// be — is a real error on a machine that may well have the app installed. +func TestPullDesktopOnlyTreatsNotExistAsNoIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + // A path routed *through* a regular file stats with ENOTDIR, not ENOENT. + blocker := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + env.syncer.desktopDir = filepath.Join(blocker, "acct", "ws") + + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoIndex=%v; want the stat failure surfaced", res.NoIndex) + } +}