Found via #73, which diagnosed this correctly. Filing it separately so the bug is tracked independently of that PR's fate.
Problem
internal/config/config.go:91 includes plugins in full-scope SyncPaths, so these files already sync:
~/.claude/plugins/known_marketplaces.json
~/.claude/plugins/installed_plugins.json
Both contain absolute local paths. Neither is covered by content path translation: IsPortableContentPath only admits history.jsonl and text formats under projects/. So a device uploads its own absolute paths verbatim, and the other device pulls a plugin index pointing at directories that don't exist there — the exact class of breakage the ${HOME}/path_map token mechanism exists to prevent.
installed_plugins.json is the more awkward of the two, because it uses paths as object keys, not just values:
{ "/Users/alice/dev/my-plugin": [ ... ] }
Any fix has to translate keys as well as values. A value-only walk silently misses the main case.
Fix
Add both files to the allowlist in IsPortableContentPath. With #82 in place this is small: content kind is already derived from the extension, so .json escaping is handled, and no JSON decode/re-encode is needed — the regex substitution operates on bytes and preserves formatting, key order and unicode escapes.
Rough shape, in internal/sync/paths.go:
switch relPath {
case "history.jsonl",
"plugins/known_marketplaces.json",
"plugins/installed_plugins.json":
return true
}
Approximately 30 lines with tests. #73 has a verified 81-line version including the conflict-path helpers below.
Do not fix this by decoding and re-encoding the JSON. #73 tried that, and reproduced against real ~/.claude data it sorts object keys, converts < to <, re-indents, and silently drops everything after the first JSON value in a .jsonl file. Byte-level substitution is the only approach that round-trips.
Related, worth doing at the same time
ConflictPath / SplitConflictPath from #73. Both IsPortableContentPath and pathContentKindFor currently find the conflict suffix with strings.Index(relPath, ".conflict."), which matches anywhere in the path — so a directory whose name contains .conflict. truncates the path and yields the wrong content kind. Parsing only the last segment fixes it.
Verification
A fix should assert, on the real plugin files:
- normalize → resolve is byte-identical
- resolve → normalize reproduces the remote bytes, so a pull doesn't leave the next push reporting a phantom modification
- a path used as an object key is translated, not just path values
TestPosixContentMappingIsBackwardCompatible still passes
Note
MigratePaths only walks projects/, so plugin blobs already on a bucket stay untranslated until the local file next changes. Either extend the migration or accept it as self-healing — worth a deliberate decision rather than leaving it implicit.
Found via #73, which diagnosed this correctly. Filing it separately so the bug is tracked independently of that PR's fate.
Problem
internal/config/config.go:91includespluginsin full-scopeSyncPaths, so these files already sync:~/.claude/plugins/known_marketplaces.json~/.claude/plugins/installed_plugins.jsonBoth contain absolute local paths. Neither is covered by content path translation:
IsPortableContentPathonly admitshistory.jsonland text formats underprojects/. So a device uploads its own absolute paths verbatim, and the other device pulls a plugin index pointing at directories that don't exist there — the exact class of breakage the${HOME}/path_maptoken mechanism exists to prevent.installed_plugins.jsonis the more awkward of the two, because it uses paths as object keys, not just values:{ "/Users/alice/dev/my-plugin": [ ... ] }Any fix has to translate keys as well as values. A value-only walk silently misses the main case.
Fix
Add both files to the allowlist in
IsPortableContentPath. With #82 in place this is small: content kind is already derived from the extension, so.jsonescaping is handled, and no JSON decode/re-encode is needed — the regex substitution operates on bytes and preserves formatting, key order and unicode escapes.Rough shape, in
internal/sync/paths.go:Approximately 30 lines with tests. #73 has a verified 81-line version including the conflict-path helpers below.
Do not fix this by decoding and re-encoding the JSON. #73 tried that, and reproduced against real
~/.claudedata it sorts object keys, converts<to<, re-indents, and silently drops everything after the first JSON value in a.jsonlfile. Byte-level substitution is the only approach that round-trips.Related, worth doing at the same time
ConflictPath/SplitConflictPathfrom #73. BothIsPortableContentPathandpathContentKindForcurrently find the conflict suffix withstrings.Index(relPath, ".conflict."), which matches anywhere in the path — so a directory whose name contains.conflict.truncates the path and yields the wrong content kind. Parsing only the last segment fixes it.Verification
A fix should assert, on the real plugin files:
TestPosixContentMappingIsBackwardCompatiblestill passesNote
MigratePathsonly walksprojects/, so plugin blobs already on a bucket stay untranslated until the local file next changes. Either extend the migration or accept it as self-healing — worth a deliberate decision rather than leaving it implicit.