Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
43fee29
fix(sync): resolve append-only transcript conflicts by comparing bytes
sjalife Aug 2, 2026
e6e6d12
fix(sync): never sync per-machine debris (.lock and .conflict artifacts)
sjalife Aug 2, 2026
f84476e
feat(storage): typed ErrNotFound so callers can distinguish missing f…
sjalife Aug 2, 2026
7ff6e93
fix(sync): union-merge history.jsonl instead of last-writer-wins
sjalife Aug 2, 2026
710ef08
feat(sync): union-merge genuinely diverged session transcripts
sjalife Aug 2, 2026
84a0659
feat(sync): sync Claude Code Desktop session records
sjalife Aug 2, 2026
6532402
fix(cli): report per-file sync errors to stderr in quiet mode
sjalife Aug 2, 2026
ad31e93
sync: JSON-escape substituted paths in .json/.jsonl content
Aug 4, 2026
cd8e9cd
sync: detect Desktop session store on Linux and macOS
Aug 4, 2026
93fe983
sync: translate cwd/originCwd in Desktop session records across devices
Aug 4, 2026
120a25d
feat(cli): add `desktop forget` to permanently remove a session record
Aug 5, 2026
e313bce
feat(cli): add `desktop list` to find session ids for `desktop forget`
Aug 5, 2026
0cd7b50
fix(sync): resolve cwd tokens for display in ListCCDSessions
Aug 5, 2026
f0fe403
webdav: reuse pooled connections to avoid concurrent-handshake TLS race
Aug 4, 2026
2931d4a
fix(sync): make TestCCDSessionsDirDetection GOOS-aware
austinc3030 Aug 24, 2026
cbe2774
feat(sync): skip Desktop pointers without a synced transcript; guard …
austinc3030 Aug 24, 2026
3495e35
Potential fix for pull request finding
austinc3030 Aug 24, 2026
3161800
storage: raise request timeout and max download size for large transc…
Aug 30, 2026
343379f
sync: raise max decompressed size to match download size cap
Aug 30, 2026
62bcd60
fix: address PR #86 review comments
Aug 30, 2026
a4daf88
webdav: fix directory-creation race and silent MKCOL failures
Sep 3, 2026
ff95c5f
fix: address PR review feedback
austinc3030 Sep 9, 2026
0931ada
fix: address additional PR review feedback
austinc3030 Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions cmd/claude-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func main() {
updateCmd(),
changelogCmd(),
mcpCmd(),
desktopCmd(),
autoCmd(),
pathsCmd(),
)
Expand Down Expand Up @@ -1010,6 +1011,18 @@ func runWebDAVWizard(webdavURL, username, password, pathPrefix string) (*storage
return cfg, nil
}

// reportQuietErrors prints per-file sync errors to stderr in quiet mode.
// Session hooks run with -q, which suppresses all normal output — but a
// failure must never be invisible.
func reportQuietErrors(result *sync.SyncResult) {
if !quiet || result == nil {
return
}
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, "claude-sync: %v\n", e)
}
}

func pushCmd() *cobra.Command {
var includeMCP bool

Expand Down Expand Up @@ -1072,6 +1085,8 @@ func pushCmd() *cobra.Command {
return err
}

reportQuietErrors(result)

if !quiet {
fmt.Println() // Clear the progress line

Expand Down Expand Up @@ -1201,6 +1216,8 @@ Examples:
return err
}

reportQuietErrors(result)

if !quiet {
fmt.Println() // Clear the progress line

Expand Down Expand Up @@ -2732,6 +2749,164 @@ func printReleaseBody(body string) {
}
}

// Desktop session record commands

func desktopCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "desktop",
Short: "Manage Claude Desktop session records",
Long: `Manage Claude Desktop Code-tab session pointer records synced under _ccd-sessions/.`,
}
cmd.AddCommand(desktopListCmd(), desktopForgetCmd())
return cmd
}

func desktopListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List Desktop session records on remote storage",
Long: `List every Claude Desktop session pointer record on remote storage —
every device's records, not just this machine's — with the sessionId and
cliSessionId "claude-sync desktop forget" needs.`,
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
}

ctx := context.Background()
records, err := syncer.ListCCDSessions(ctx)
if err != nil {
return err
}
if quiet {
return nil
}
if len(records) == 0 {
fmt.Printf("%s⋯%s No desktop session records found\n", colorDim, colorReset)
return nil
}

for _, r := range records {
title := r.Title
if title == "" {
title = "(untitled)"
}
archived := ""
if r.IsArchived {
archived = fmt.Sprintf(" %s[archived]%s", colorDim, colorReset)
}
fmt.Printf("%s%s%s%s\n", colorBold, title, colorReset, archived)
if r.CWD != "" {
fmt.Printf(" cwd: %s\n", r.CWD)
}
if r.SessionID != "" {
fmt.Printf(" sessionId: %s\n", r.SessionID)
}
if r.CLISessionID != "" {
fmt.Printf(" cliSessionId: %s\n", r.CLISessionID)
}
if r.LastActivityAt > 0 {
fmt.Printf(" lastActive: %s\n", time.UnixMilli(r.LastActivityAt).Format("2006-01-02 15:04"))
}
fmt.Println()
}
return nil
},
}
}

func desktopForgetCmd() *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "forget <session-id>",
Short: "Permanently remove a Desktop session record everywhere",
Long: `Permanently remove a Claude Desktop session pointer record — locally
(this machine's session store) and from remote storage.

Desktop session records are "only grows" by design: a normal push or pull
never deletes one just because a local copy disappeared, precisely so a
session doesn't vanish from a device's sidebar just because another
device's file briefly went missing. That means a genuinely broken record —
an orphaned fork with no cliSessionId, a duplicate left over from before a
project moved, or anything else you actually want gone — has no way to
leave the bucket on its own. This command is the explicit, opt-in way to
remove one.

<session-id> matches either the record's own sessionId (the "local_<uuid>"
value) or its cliSessionId — whichever you have on hand. It must match
exactly; there is no partial or fuzzy matching.

This does not touch the underlying conversation transcript under
~/.claude/projects — only the Desktop-visible pointer. To remove the
transcript too, delete it (or delete the conversation via Desktop's UI)
and run a regular push, on every device that has a copy.

Examples:
claude-sync desktop forget local_338bc4b3-d3fd-4e32-b1a6-39000abc6198
claude-sync desktop forget 8337ee9d-7f51-4ae3-bfa2-0b549b8ab028`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]

if !force {
fmt.Println()
printWarning(fmt.Sprintf("This will permanently remove the Desktop session record %q", id))
printWarning("locally and from remote storage. It does not touch the conversation transcript.")
fmt.Println()
fmt.Printf("%sType 'forget' to confirm:%s ", colorYellow, colorReset)
reader := bufio.NewReader(os.Stdin)
confirm, _ := reader.ReadString('\n')
if strings.TrimSpace(confirm) != "forget" {
fmt.Println("Aborted.")
return nil
}
fmt.Println()
}

cfg, err := config.Load()
if err != nil {
return err
}
syncer, err := sync.NewSyncer(cfg, quiet)
if err != nil {
return err
}

ctx := context.Background()
result, err := syncer.ForgetCCDSession(ctx, id)
if err != nil {
return fmt.Errorf("desktop forget failed: %w", err)
}

if quiet {
return nil
}
if len(result.RemovedRemoteKeys) == 0 && len(result.RemovedLocalPaths) == 0 {
fmt.Printf("%s⋯%s No matching record found for %q\n", colorDim, colorReset, id)
return nil
}
for _, key := range result.RemovedRemoteKeys {
fmt.Printf(" %s-%s remote: %s\n", colorYellow, colorReset, key)
}
for _, path := range result.RemovedLocalPaths {
fmt.Printf(" %s-%s local: %s\n", colorYellow, colorReset, path)
}
fmt.Printf("%s✓%s Removed %d remote record(s), %d local file(s)\n",
colorGreen, colorReset, len(result.RemovedRemoteKeys), len(result.RemovedLocalPaths))
return nil
},
}

cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
return cmd
}

// MCP sync commands

func mcpCmd() *cobra.Command {
Expand Down
3 changes: 3 additions & 0 deletions internal/storage/gcs/gcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ func (c *Client) List(ctx context.Context, prefix string) ([]appstorage.ObjectIn
func (c *Client) Head(ctx context.Context, key string) (*appstorage.ObjectInfo, error) {
attrs, err := c.client.Bucket(c.bucket).Object(key).Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, fmt.Errorf("%s: %w", key, appstorage.ErrNotFound)
}
return nil, err
}

Expand Down
4 changes: 4 additions & 0 deletions internal/storage/r2/r2.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ func (c *Client) Head(ctx context.Context, key string) (*storage.ObjectInfo, err
Key: aws.String(key),
})
if err != nil {
var notFound *types.NotFound
if errors.As(err, &notFound) {
return nil, fmt.Errorf("%s: %w", key, storage.ErrNotFound)
}
return nil, err
}

Expand Down
4 changes: 4 additions & 0 deletions internal/storage/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ func (c *Client) Head(ctx context.Context, key string) (*storage.ObjectInfo, err
Key: aws.String(key),
})
if err != nil {
var notFound *types.NotFound
if errors.As(err, &notFound) {
return nil, fmt.Errorf("%s: %w", key, storage.ErrNotFound)
}
return nil, err
}

Expand Down
23 changes: 21 additions & 2 deletions internal/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ package storage

import (
"context"
"errors"
"fmt"
"time"
)

// ErrNotFound distinguishes a missing object from transient failures
// (timeouts, throttling, 5xx). Adapters wrap their provider-specific
// not-found errors with it; callers that must not confuse "absent" with
// "unreachable" — such as the history merge-on-push, where the difference is
// clobbering the bucket union — check it via IsNotFound.
var ErrNotFound = errors.New("object not found")

// IsNotFound reports whether err indicates the object does not exist.
func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}

// Provider represents a storage provider type
type Provider string

Expand All @@ -16,9 +29,15 @@ const (
ProviderWebDAV Provider = "webdav"
)

// MaxDownloadSize is the maximum allowed size for a single downloaded object (100MB).
// MaxDownloadSize is the maximum allowed size for a single downloaded object.
// This prevents memory exhaustion from oversized or malicious remote files.
const MaxDownloadSize = 100 * 1024 * 1024
//
// Was 100MB. Raised after a real transcript from a single long-running
// session hit 887MB (and a sibling in the same project 930MB) — legitimate
// conversation history, not corruption. 2GB leaves headroom for that pattern
// to continue while still bounding worst-case memory use for a single
// in-memory download on any machine this runs on.
const MaxDownloadSize = 2 * 1024 * 1024 * 1024

// ObjectInfo contains metadata about a stored object
type ObjectInfo struct {
Expand Down
Loading