@narumitw/pi-sync 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  7. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  8. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  9. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  10. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  11. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  12. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  13. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  14. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  15. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  16. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  17. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  18. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  19. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  20. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  21. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  22. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  23. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  24. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  25. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  26. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  27. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  28. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  29. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  30. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  31. package/dist/chunks/chunk-NLS2NV3K.ts +1746 -0
  32. package/dist/chunks/chunk-NLS2NV3K.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-4WPGCXFB.ts +175 -0
  50. package/dist/chunks/conflict-review-4WPGCXFB.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-RHNECOOG.ts} +309 -508
  71. package/dist/chunks/sync-operations-RHNECOOG.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +5 -5
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +428 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
@@ -19,11 +19,16 @@ export interface Snapshot {
19
19
  syncSessions?: boolean;
20
20
  /** Portable, credential-free included-content intent. Absent on legacy snapshots. */
21
21
  selection?: SnapshotSelection;
22
+ localFields?: string[];
22
23
  files: SnapshotFile[];
23
24
  }
24
25
 
25
26
  export interface SnapshotOptions {
26
27
  signal?: AbortSignal;
28
+ /** Caller-owned session/generation guard, rechecked at file-mutation boundaries. */
29
+ validateMutation?: () => void;
30
+ /** Reviewed regular-file preimages; null means the path was absent. Checked under mutation queues. */
31
+ expectedFileHashes?: Readonly<Record<string, string | null>>;
27
32
  include?: string[];
28
33
  sessionDir?: string;
29
34
  /** Temporary internal projections while snapshot storage remains wire-compatible. */
@@ -11,10 +11,37 @@ export async function readJsonIfExists<T>(filePath: string): Promise<T | undefin
11
11
  }
12
12
  }
13
13
 
14
- export async function writeJson(filePath: string, value: unknown) {
15
- await fs.mkdir(path.dirname(filePath), { recursive: true });
14
+ export async function writeJson(filePath: string, value: unknown, options: { maxBytes?: number } = {}) {
15
+ const content = `${JSON.stringify(value, null, "\t")}\n`;
16
+ if (options.maxBytes !== undefined && Buffer.byteLength(content) > options.maxBytes)
17
+ throw new Error(
18
+ "Private state document exceeds its storage bound; preserve existing evidence and use reviewed recovery.",
19
+ );
20
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
16
21
  const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
17
- await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
18
- if (process.platform !== "win32") await fs.chmod(temp, 0o600);
19
- await fs.rename(temp, filePath);
22
+ try {
23
+ const handle = await fs.open(temp, "wx", 0o600);
24
+ try {
25
+ await handle.writeFile(content);
26
+ await handle.sync();
27
+ } finally {
28
+ await handle.close();
29
+ }
30
+ if (process.platform !== "win32") await fs.chmod(temp, 0o600);
31
+ await fs.rename(temp, filePath);
32
+ await syncDirectory(path.dirname(filePath));
33
+ } finally {
34
+ await fs.rm(temp, { force: true });
35
+ }
36
+ }
37
+
38
+ /** POSIX rename/unlink durability; Windows does not expose directory fsync through Node. */
39
+ export async function syncDirectory(directory: string) {
40
+ if (process.platform === "win32") return;
41
+ const handle = await fs.open(directory, "r");
42
+ try {
43
+ await handle.sync();
44
+ } finally {
45
+ await handle.close();
46
+ }
20
47
  }
@@ -0,0 +1,182 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { backendIdentityCoordinates } from "../backends/backend-identity.js";
5
+ import type { AnySyncConfig } from "../settings/settings-types.js";
6
+ import type { Snapshot, SnapshotFile } from "../snapshot/snapshot-types.js";
7
+ import { collidingPaths, planFileMerge } from "../sync/file-merge-planner.js";
8
+ import { parseSettingsDocument } from "../sync/json-document.js";
9
+ import { validateSession } from "../sync/session-merge.js";
10
+ import { isMergeTextPath, text } from "../sync/text-merge.js";
11
+ import { syncDirectory, writeJson } from "./json-file.js";
12
+ import type { SyncState } from "./state-types.js";
13
+ import { statePathForConfig, syncStateFingerprint } from "./sync-state-store.js";
14
+
15
+ interface MergeBaseline {
16
+ version: 1;
17
+ identity: string;
18
+ acceptedState: string;
19
+ files: SnapshotFile[];
20
+ }
21
+ const MAX_BYTES = 192 * 1024 * 1024;
22
+ function eligible(file: SnapshotFile) {
23
+ try {
24
+ const bytes = Buffer.from(file.contentBase64, "base64");
25
+ if (file.path === "settings.json") parseSettingsDocument(bytes);
26
+ else if (isMergeTextPath(file.path)) text(bytes);
27
+ else if (file.path.startsWith("sessions/") && file.path.endsWith(".jsonl")) validateSession(bytes);
28
+ else return false;
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function identity(config: AnySyncConfig) {
36
+ return JSON.stringify([
37
+ config.setupName,
38
+ backendIdentityCoordinates(config),
39
+ config.include,
40
+ config.localFields ?? [],
41
+ ]);
42
+ }
43
+ function directory(config: AnySyncConfig) {
44
+ return `${statePathForConfig(config)}.ancestors`;
45
+ }
46
+ async function checkDirectory(config: AnySyncConfig, create = false) {
47
+ const target = directory(config);
48
+ if (create) await fs.mkdir(target, { recursive: true, mode: 0o700 });
49
+ const stat = await fs.lstat(target);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
51
+ throw new Error("Unsafe private ancestor directory; preserve evidence.");
52
+ }
53
+ function baselinePath(config: AnySyncConfig, state: SyncState) {
54
+ return path.join(directory(config), `${syncStateFingerprint(state)}.json`);
55
+ }
56
+
57
+ /** Stage before accepted state, retain its previous ancestor until journal retirement. Never bootstrap from current edits. */
58
+ export async function stageMergeBaseline(
59
+ config: AnySyncConfig,
60
+ snapshot: Snapshot,
61
+ accepted: SyncState,
62
+ previous?: SyncState,
63
+ ) {
64
+ const collisions = collidingPaths(snapshot.files.map((file) => file.path));
65
+ const files = snapshot.files.filter((file) => {
66
+ return (
67
+ !collisions.has(file.path) &&
68
+ accepted.lastFileHashes[file.path] === file.sha256 &&
69
+ (file.path === "settings.json" || config.mergeContent) &&
70
+ eligible(file)
71
+ );
72
+ });
73
+ if (previous) {
74
+ const stagedPaths = new Set(files.map((file) => file.path));
75
+ let ancestors: SnapshotFile[] = [];
76
+ try {
77
+ ancestors = (await readMergeAncestors(config, previous)) ?? [];
78
+ } catch {
79
+ /* Keep unknown evidence; missing bytes are never invented. */
80
+ }
81
+ for (const ancestor of ancestors)
82
+ if (
83
+ !collisions.has(ancestor.path) &&
84
+ accepted.lastFileHashes[ancestor.path] === ancestor.sha256 &&
85
+ !stagedPaths.has(ancestor.path)
86
+ ) {
87
+ files.push(ancestor);
88
+ stagedPaths.add(ancestor.path);
89
+ }
90
+ }
91
+ const plan = planFileMerge({ baseline: {}, local: files, remote: [], selectionCompatible: true });
92
+ if (plan.kind !== "planned" || plan.conflicts.length)
93
+ throw new Error("Cannot stage unverified merge ancestor bytes.");
94
+ const record: MergeBaseline = {
95
+ version: 1,
96
+ identity: identity(config),
97
+ acceptedState: syncStateFingerprint(accepted),
98
+ files,
99
+ };
100
+ await checkDirectory(config, true);
101
+ await writeJson(baselinePath(config, accepted), record, { maxBytes: MAX_BYTES });
102
+ }
103
+
104
+ async function readBaselineRecord(
105
+ config: AnySyncConfig,
106
+ target: string,
107
+ acceptedState: string,
108
+ validate: () => void = () => {},
109
+ ): Promise<MergeBaseline> {
110
+ const stat = await fs.lstat(target);
111
+ validate();
112
+ if (!stat.isFile() || stat.size > MAX_BYTES || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
113
+ throw new Error("Unsafe cache.");
114
+ const record = JSON.parse(await fs.readFile(target, "utf8")) as MergeBaseline;
115
+ validate();
116
+ if (
117
+ record.version !== 1 ||
118
+ record.identity !== identity(config) ||
119
+ record.acceptedState !== acceptedState ||
120
+ !Array.isArray(record.files) ||
121
+ record.files.length > 16_384 ||
122
+ record.files.some((file) => !eligible(file))
123
+ )
124
+ throw new Error("Invalid cache identity.");
125
+ const plan = planFileMerge({ baseline: {}, local: record.files, remote: [], selectionCompatible: true });
126
+ if (plan.kind !== "planned" || plan.conflicts.length) throw new Error("Invalid cache bytes.");
127
+ return record;
128
+ }
129
+
130
+ export async function readMergeAncestors(config: AnySyncConfig, state: SyncState): Promise<SnapshotFile[] | undefined> {
131
+ let record: MergeBaseline;
132
+ try {
133
+ await checkDirectory(config);
134
+ record = await readBaselineRecord(config, baselinePath(config, state), syncStateFingerprint(state));
135
+ for (const file of record.files) {
136
+ if (
137
+ state.lastFileHashes[file.path] !== file.sha256 ||
138
+ createHash("sha256").update(Buffer.from(file.contentBase64, "base64")).digest("hex") !== file.sha256
139
+ )
140
+ throw new Error("Cache hash mismatch.");
141
+ }
142
+ } catch (error) {
143
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
144
+ throw new Error("Merge ancestor is invalid; preserve its private cache and review the conflict.");
145
+ }
146
+ return record.files;
147
+ }
148
+ export async function readMergeAncestor(config: AnySyncConfig, state: SyncState, filePath: string) {
149
+ const file = (await readMergeAncestors(config, state))?.find((file) => file.path === filePath);
150
+ return file ? Buffer.from(file.contentBase64, "base64") : undefined;
151
+ }
152
+
153
+ /** Caller must hold the sync operation lock and retire the commit journal before pruning. */
154
+ export async function pruneMergeBaselines(config: AnySyncConfig, state: SyncState, validate: () => void) {
155
+ validate();
156
+ const keep = path.basename(baselinePath(config, state));
157
+ let names: string[];
158
+ try {
159
+ await checkDirectory(config);
160
+ validate();
161
+ names = await fs.readdir(directory(config));
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
164
+ throw error;
165
+ }
166
+ for (const name of names) {
167
+ validate();
168
+ if (name === keep || !/^[a-f0-9]{64}\.json$/u.test(name)) continue;
169
+ const target = path.join(directory(config), name);
170
+ try {
171
+ await readBaselineRecord(config, target, name.slice(0, -5), validate);
172
+ } catch {
173
+ validate();
174
+ continue; // Unknown/corrupted evidence is not an owned pruning candidate.
175
+ }
176
+ validate();
177
+ await fs.rm(target);
178
+ validate();
179
+ }
180
+ await syncDirectory(directory(config));
181
+ validate();
182
+ }
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ import { isPathInside } from "../paths.js";
3
+ import { mergePathIdentity } from "../sync/file-merge-planner.js";
4
+ import { syncDirectory } from "./json-file.js";
5
+
6
+ /** Persist created ancestor names as well as the immediate rename/delete directory. */
7
+ export async function syncMutationParents(
8
+ targets: readonly string[],
9
+ roots: readonly string[],
10
+ options: { signal?: AbortSignal; validateMutation?: () => void } = {},
11
+ ) {
12
+ const directories = new Set<string>();
13
+ for (const target of targets) {
14
+ const root = roots.map((root) => path.resolve(root)).find((root) => isPathInside(root, target));
15
+ if (!root) throw new Error("Unowned mutation directory.");
16
+ for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
17
+ directories.add(parent);
18
+ if (mergePathIdentity(parent) === mergePathIdentity(root)) break;
19
+ if (path.dirname(parent) === parent) throw new Error("Mutation directory escaped its owned root.");
20
+ }
21
+ }
22
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
23
+ options.signal?.throwIfAborted();
24
+ options.validateMutation?.();
25
+ try {
26
+ await syncDirectory(directory);
27
+ } catch (error) {
28
+ const code = (error as NodeJS.ErrnoException).code;
29
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
30
+ }
31
+ options.signal?.throwIfAborted();
32
+ options.validateMutation?.();
33
+ }
34
+ }
@@ -6,6 +6,10 @@ export interface SyncState {
6
6
  lastRemoteEtag?: string;
7
7
  lastFileHashes: Record<string, string>;
8
8
  include?: string[];
9
+ localFields?: string[];
10
+ lastObservedSnapshot?: string;
11
+ lastObservedRevision?: string;
12
+ unresolved?: { paths: string[]; artifact: string }[];
9
13
  /** Legacy state fields are read only so v3 can detect and replace stale policy state. */
10
14
  syncFiles?: string[];
11
15
  syncSessions?: boolean;
@@ -39,8 +39,21 @@ export async function readStateForConfig(config: AnySyncConfig): Promise<SyncSta
39
39
  );
40
40
  }
41
41
 
42
- export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
42
+ export async function writeStateForConfig(
43
+ config: AnySyncConfig,
44
+ state: SyncState,
45
+ snapshot?: import("../snapshot/snapshot-types.js").Snapshot,
46
+ validate: () => void = () => {},
47
+ ) {
48
+ validate();
49
+ if (snapshot) {
50
+ const { stageMergeBaseline } = await import("./merge-baseline-store.js");
51
+ validate();
52
+ await stageMergeBaseline(config, snapshot, state);
53
+ validate();
54
+ }
43
55
  await writeJson(statePathForConfig(config), state);
56
+ validate();
44
57
  }
45
58
 
46
59
  export function statePathForConfig(config: AnySyncConfig) {
@@ -5,7 +5,7 @@ import { loadConfig, loadPartialConfig } from "../settings/config.js";
5
5
  import { isMissingConfigError } from "../settings/config-errors.js";
6
6
  import { consumeLocalConfigMigrationNotice } from "../settings/config-file.js";
7
7
  import { readLocalConfigObject } from "../settings/settings-store.js";
8
- import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
8
+ import { sessionDirFromContext, snapshotOptionsForContext } from "../snapshot/session-paths.js";
9
9
  import { recoverSnapshotTransactionsOnStartup } from "../snapshot/snapshot-transaction.js";
10
10
  import { withLock } from "../state/lock.js";
11
11
  import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
@@ -15,6 +15,7 @@ import { safeTerminalText } from "../ui/terminal-text.js";
15
15
  import { throwIfAborted } from "./signals.js";
16
16
  import { errorMessage } from "./sync-errors.js";
17
17
  import type { SyncLoaders } from "./sync-loaders.js";
18
+ import { captureMutationOwner } from "./sync-local.js";
18
19
 
19
20
  const AUTO_SYNC_OPTIONS: CommandOptions = {
20
21
  yes: true,
@@ -32,7 +33,14 @@ export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
32
33
  if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
33
34
  // Recovery can restore managed files: finish it before Pi accepts user edits.
34
35
  // Unlike remote inspection, it must never be detached behind startup.
35
- await recoverSnapshotTransactionsOnStartup();
36
+ const validateMutation = captureMutationOwner(ctx, signal);
37
+ const currentFile = ctx.sessionManager.getSessionFile?.();
38
+ await recoverSnapshotTransactionsOnStartup({
39
+ signal,
40
+ sessionDir: sessionDirFromContext(ctx),
41
+ protectedTargets: currentFile ? [currentFile] : [],
42
+ validateMutation,
43
+ });
36
44
  throwIfAborted(signal);
37
45
  try {
38
46
  const settings = await readLocalConfigObject();
@@ -52,7 +60,7 @@ export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
52
60
  const config = await loadConfig();
53
61
  throwIfAborted(signal);
54
62
  configureSyncStatus(ctx, config.showStatus);
55
- return config.automatic ? config : undefined;
63
+ return config.automatic || config.automaticTransfer ? config : undefined;
56
64
  } catch (error) {
57
65
  throwIfAborted(signal);
58
66
  if (!isMissingConfigError(error)) {
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AnySyncConfig } from "../settings/settings-types.js";
4
+ import { syncDirectory } from "../state/json-file.js";
5
+ import { readStateForConfig, statePathForConfig } from "../state/sync-state-store.js";
6
+ import {
7
+ type ConflictArtifact,
8
+ type CreatedConflictArtifact,
9
+ conflictArtifactFingerprint,
10
+ conflictDirectory,
11
+ } from "./conflict-artifacts.js";
12
+ import { readMergeJournal } from "./merge-journal.js";
13
+
14
+ /** Attempt-owned cleanup only: unknown evidence remains the retention policy's responsibility. */
15
+ export async function releaseConflictArtifacts(
16
+ config: AnySyncConfig,
17
+ statePath: string,
18
+ created: readonly CreatedConflictArtifact[],
19
+ ) {
20
+ if (!created.length || statePathForConfig(config) !== statePath) return;
21
+ try {
22
+ const state = await readStateForConfig(config);
23
+ if (statePathForConfig(config) !== statePath) return;
24
+ const journal = await readMergeJournal(config);
25
+ if (statePathForConfig(config) !== statePath) return;
26
+ const referenced = new Set([
27
+ ...(state.unresolved ?? []).map((group) => group.artifact),
28
+ ...(journal?.progress?.groups ?? []).map((group) => group.artifact),
29
+ ]);
30
+ for (const lease of created) {
31
+ if (referenced.has(lease.token) || lease.target !== path.join(conflictDirectory(config), `${lease.token}.json`))
32
+ continue;
33
+ try {
34
+ const stat = await fs.lstat(lease.target);
35
+ if (
36
+ !stat.isFile() ||
37
+ stat.nlink !== 1 ||
38
+ stat.dev !== lease.dev ||
39
+ stat.ino !== lease.ino ||
40
+ stat.size !== lease.size
41
+ )
42
+ continue;
43
+ const artifact = JSON.parse(await fs.readFile(lease.target, "utf8")) as ConflictArtifact;
44
+ if (conflictArtifactFingerprint(artifact) !== lease.fingerprint || statePathForConfig(config) !== statePath)
45
+ continue;
46
+ const latest = await fs.lstat(lease.target);
47
+ if (
48
+ latest.dev !== lease.dev ||
49
+ latest.ino !== lease.ino ||
50
+ latest.nlink !== 1 ||
51
+ statePathForConfig(config) !== statePath
52
+ )
53
+ continue;
54
+ await fs.unlink(lease.target);
55
+ await syncDirectory(path.dirname(lease.target));
56
+ } catch {
57
+ /* Missing, replaced or unreadable evidence is not ours to delete. */
58
+ }
59
+ }
60
+ } catch {
61
+ /* Invalid state/journal or unavailable storage cannot prove lack of references. */
62
+ }
63
+ }
@@ -0,0 +1,195 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { AnySyncConfig } from "../settings/settings-types.js";
5
+ import type { Snapshot, SnapshotFile } from "../snapshot/snapshot-types.js";
6
+ import { syncDirectory, writeJson } from "../state/json-file.js";
7
+ import type { SyncState } from "../state/state-types.js";
8
+ import { statePathForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
9
+ import { planFileMerge } from "./file-merge-planner.js";
10
+
11
+ export { conflictGroups } from "./conflict-groups.js";
12
+
13
+ import { mergeJournalIdentity } from "./merge-journal.js";
14
+ export interface ConflictArtifact {
15
+ version: 1;
16
+ identity: string;
17
+ ancestors?: SnapshotFile[];
18
+ state: SyncState;
19
+ local: Snapshot;
20
+ remote: Snapshot;
21
+ groups: { paths: string[]; reasons: string[] }[];
22
+ observed: { snapshotId: string; revision: string };
23
+ }
24
+ const LIMIT = 192 * 1024 * 1024;
25
+ export function conflictArtifactFingerprint(artifact: ConflictArtifact) {
26
+ return createHash("sha256").update(JSON.stringify(artifact)).digest("hex");
27
+ }
28
+ export function conflictDirectory(config: AnySyncConfig) {
29
+ return `${statePathForConfig(config)}.conflicts`;
30
+ }
31
+ async function directory(config: AnySyncConfig, create = false) {
32
+ const target = conflictDirectory(config);
33
+ if (create) await fs.mkdir(target, { recursive: true, mode: 0o700 });
34
+ const stat = await fs.lstat(target);
35
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
36
+ throw new Error("Unsafe private conflict directory.");
37
+ return target;
38
+ }
39
+ function tokenPath(config: AnySyncConfig, token: string) {
40
+ if (!/^[a-f0-9-]{36}$/u.test(token)) throw new Error("Invalid conflict token.");
41
+ return path.join(conflictDirectory(config), `${token}.json`);
42
+ }
43
+ export interface CreatedConflictArtifact {
44
+ token: string;
45
+ target: string;
46
+ dev: number;
47
+ ino: number;
48
+ size: number;
49
+ fingerprint: string;
50
+ }
51
+ export async function saveConflictArtifact(
52
+ config: AnySyncConfig,
53
+ backend: string,
54
+ artifact: Omit<ConflictArtifact, "version" | "identity">,
55
+ validate: () => void,
56
+ onCreated?: (created: CreatedConflictArtifact) => void,
57
+ ) {
58
+ validate();
59
+ const identity = mergeJournalIdentity(config, backend);
60
+ const local = Object.fromEntries(artifact.local.files.map((file) => [file.path, file.sha256]));
61
+ const remote = Object.fromEntries(artifact.remote.files.map((file) => [file.path, file.sha256]));
62
+ const fingerprint = createHash("sha256")
63
+ .update(
64
+ JSON.stringify([
65
+ identity,
66
+ artifact.groups.map((group) =>
67
+ group.paths.map((filePath) => [
68
+ filePath,
69
+ artifact.state.lastFileHashes[filePath] ?? null,
70
+ local[filePath] ?? null,
71
+ remote[filePath] ?? null,
72
+ ]),
73
+ ),
74
+ ]),
75
+ )
76
+ .digest("hex")
77
+ .slice(0, 32);
78
+ const token = `${fingerprint.slice(0, 8)}-${fingerprint.slice(8, 12)}-${fingerprint.slice(12, 16)}-${fingerprint.slice(16, 20)}-${fingerprint.slice(20)}`;
79
+ await directory(config, true);
80
+ validate();
81
+ const reuse = async () => {
82
+ const existing = await readConflictArtifact(config, backend, token);
83
+ validate();
84
+ const oldLocal = Object.fromEntries(existing.local.files.map((file) => [file.path, file.sha256]));
85
+ const oldRemote = Object.fromEntries(existing.remote.files.map((file) => [file.path, file.sha256]));
86
+ if (
87
+ JSON.stringify(existing.groups.map((group) => group.paths)) !==
88
+ JSON.stringify(artifact.groups.map((group) => group.paths)) ||
89
+ artifact.groups.some((group) =>
90
+ group.paths.some(
91
+ (filePath) =>
92
+ existing.state.lastFileHashes[filePath] !== artifact.state.lastFileHashes[filePath] ||
93
+ oldLocal[filePath] !== local[filePath] ||
94
+ oldRemote[filePath] !== remote[filePath],
95
+ ),
96
+ )
97
+ )
98
+ throw new Error("Conflict identity does not match retained immutable evidence.");
99
+ return token;
100
+ };
101
+ const target = tokenPath(config, token);
102
+ try {
103
+ await fs.lstat(target);
104
+ validate();
105
+ return await reuse();
106
+ } catch (error) {
107
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
108
+ }
109
+ const temporary = `${target}.${randomUUID()}.pending`;
110
+ const candidate: ConflictArtifact = { version: 1, identity, ...artifact };
111
+ try {
112
+ await writeJson(temporary, candidate, { maxBytes: LIMIT });
113
+ validate();
114
+ const stat = await fs.lstat(temporary);
115
+ validate();
116
+ try {
117
+ // Publish immutable evidence without replacing a concurrent/pre-existing owner.
118
+ await fs.link(temporary, target);
119
+ } catch (error) {
120
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
121
+ validate();
122
+ return await reuse();
123
+ }
124
+ // Register ownership before any cancellation or durability await can fail.
125
+ onCreated?.({
126
+ token,
127
+ target,
128
+ dev: stat.dev,
129
+ ino: stat.ino,
130
+ size: stat.size,
131
+ fingerprint: conflictArtifactFingerprint(candidate),
132
+ });
133
+ validate();
134
+ await syncDirectory(path.dirname(target));
135
+ validate();
136
+ await syncDirectory(path.dirname(conflictDirectory(config)));
137
+ validate();
138
+ return token;
139
+ } finally {
140
+ await fs.rm(temporary, { force: true });
141
+ }
142
+ }
143
+ export async function readConflictArtifact(config: AnySyncConfig, backend: string, token: string) {
144
+ try {
145
+ await directory(config);
146
+ const target = tokenPath(config, token);
147
+ const stat = await fs.lstat(target);
148
+ if (!stat.isFile() || stat.size > LIMIT || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
149
+ throw new Error("Unsafe conflict artifact.");
150
+ const artifact = JSON.parse(await fs.readFile(target, "utf8")) as ConflictArtifact;
151
+ if (
152
+ artifact.version !== 1 ||
153
+ artifact.identity !== mergeJournalIdentity(config, backend) ||
154
+ !Array.isArray(artifact.groups) ||
155
+ artifact.groups.length > 16_384 ||
156
+ artifact.groups.some(
157
+ (group) =>
158
+ !Array.isArray(group.paths) ||
159
+ !group.paths.length ||
160
+ group.paths.length > 16_384 ||
161
+ !Array.isArray(group.reasons),
162
+ )
163
+ )
164
+ throw new Error("Invalid conflict artifact.");
165
+ const plan = planFileMerge({
166
+ baseline: artifact.state.lastFileHashes,
167
+ local: artifact.local.files,
168
+ remote: artifact.remote.files,
169
+ selectionCompatible: true,
170
+ });
171
+ if (plan.kind !== "planned") throw new Error("Invalid conflict versions.");
172
+ const known = new Set(plan.decisions.map((item) => item.path));
173
+ if (artifact.groups.some((group) => group.paths.some((value) => !known.has(value))))
174
+ throw new Error("Invalid conflict paths.");
175
+ if (artifact.ancestors) {
176
+ if (
177
+ !Array.isArray(artifact.ancestors) ||
178
+ artifact.ancestors.length > 16_384 ||
179
+ artifact.ancestors.some((file) => artifact.state.lastFileHashes[file.path] !== file.sha256)
180
+ )
181
+ throw new Error("Invalid artifact ancestor.");
182
+ const basePlan = planFileMerge({
183
+ baseline: {},
184
+ local: artifact.ancestors,
185
+ remote: [],
186
+ selectionCompatible: true,
187
+ });
188
+ if (basePlan.kind !== "planned" || basePlan.conflicts.length) throw new Error("Invalid ancestor bytes.");
189
+ }
190
+ syncStateFingerprint(artifact.state);
191
+ return artifact;
192
+ } catch {
193
+ throw new Error("Cannot verify private conflict artifact; preserve evidence and review a fresh sync.");
194
+ }
195
+ }
@@ -0,0 +1,68 @@
1
+ import type { AnySyncConfig } from "../settings/settings-types.js";
2
+ import { type FileMergePlan, mergePathIdentity } from "./file-merge-planner.js";
3
+
4
+ /** Indexed connected closure; path relationships are identical to the conservative merge policy. */
5
+ export function conflictGroups(plan: Extract<FileMergePlan, { kind: "planned" }>, config: AnySyncConfig) {
6
+ const paths = plan.decisions.map((item) => item.path);
7
+ const parents = paths.map((_, index) => index);
8
+ const ranks = paths.map(() => 0);
9
+ const find = (index: number): number => {
10
+ while (parents[index] !== index) {
11
+ parents[index] = parents[parents[index] ?? index] ?? index;
12
+ index = parents[index] ?? index;
13
+ }
14
+ return index;
15
+ };
16
+ const join = (left: number, right: number) => {
17
+ let a = find(left);
18
+ let b = find(right);
19
+ if (a === b) return;
20
+ if ((ranks[a] ?? 0) < (ranks[b] ?? 0)) [a, b] = [b, a];
21
+ parents[b] = a;
22
+ if (ranks[a] === ranks[b]) ranks[a] = (ranks[a] ?? 0) + 1;
23
+ };
24
+ const identities = paths.map(mergePathIdentity);
25
+ const byIdentity = new Map<string, number>();
26
+ const byPath = new Map(paths.map((value, index) => [value, index]));
27
+ identities.forEach((value, index) => {
28
+ const previous = byIdentity.get(value);
29
+ if (previous !== undefined) join(index, previous);
30
+ else byIdentity.set(value, index);
31
+ });
32
+ const includes = new Set(
33
+ config.include
34
+ .filter((value) => !["settings.json", "AGENTS.md", "sessions"].includes(value))
35
+ .map(mergePathIdentity),
36
+ );
37
+ const buckets = new Map<string, number>();
38
+ const bucket = (key: string, index: number) => {
39
+ const previous = buckets.get(key);
40
+ if (previous === undefined) buckets.set(key, index);
41
+ else join(index, previous);
42
+ };
43
+ paths.forEach((value, index) => {
44
+ const root = value.split("/")[0] ?? "";
45
+ if (["extensions", "themes", "skills", "prompts"].includes(root)) bucket(`resource:${root}`, index);
46
+ const segments = (identities[index] ?? "").split("/");
47
+ let ancestor = "";
48
+ for (let length = 0; length < segments.length - 1; length++) {
49
+ ancestor = ancestor ? `${ancestor}/${segments[length]}` : (segments[length] ?? "");
50
+ const parent = byIdentity.get(ancestor);
51
+ if (parent !== undefined) join(index, parent);
52
+ if (includes.has(ancestor)) bucket(`include:${ancestor}`, index);
53
+ }
54
+ });
55
+ const groups = new Map<number, { paths: string[]; reasons: string[] }>();
56
+ for (const conflict of plan.conflicts) {
57
+ const index = byPath.get(conflict.path);
58
+ if (index === undefined) throw new Error("Conflict is missing from managed decisions.");
59
+ const key = find(index);
60
+ const group = groups.get(key) ?? { paths: [], reasons: [] };
61
+ if (!group.reasons.includes(conflict.reason)) group.reasons.push(conflict.reason);
62
+ groups.set(key, group);
63
+ }
64
+ paths.forEach((value, index) => {
65
+ groups.get(find(index))?.paths.push(value);
66
+ });
67
+ return [...groups.values()].map((group) => ({ ...group, paths: group.paths.sort() }));
68
+ }