@narumitw/pi-sync 0.51.1 → 0.52.0

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-2GUXI2XT.ts +1702 -0
  7. package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
  8. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  9. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  10. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  11. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  12. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  13. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  14. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  15. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  16. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  17. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  18. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  19. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  20. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  21. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  22. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  23. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  24. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  25. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  26. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  27. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  28. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  29. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  30. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  31. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  32. package/dist/chunks/chunk-IJ6VV6FN.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-T3PB7ZSD.ts +175 -0
  50. package/dist/chunks/conflict-review-T3PB7ZSD.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-27VQUDH2.ts} +309 -508
  71. package/dist/chunks/sync-operations-27VQUDH2.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 +380 -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
@@ -8,6 +8,7 @@ import {
8
8
  } from "../snapshot/snapshot.js";
9
9
  import type { Snapshot } from "../snapshot/snapshot-types.js";
10
10
  import type { SyncState } from "../state/state-types.js";
11
+ import { portableSnapshot, sameLocalFields } from "./local-fields.js";
11
12
  import {
12
13
  customIncludePathsByLower,
13
14
  includeFromSelectionConfig,
@@ -15,10 +16,13 @@ import {
15
16
  type SyncSelectionConfig,
16
17
  } from "./sync-policy.js";
17
18
 
18
- type SyncPolicyConfig = SyncSelectionConfig;
19
+ type SyncPolicyConfig = SyncSelectionConfig & { localFields?: string[] };
19
20
 
20
21
  export function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncPolicyConfig) {
21
- return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
22
+ return (
23
+ (Boolean(state.lastAppliedSnapshot) && !sameLocalFields(state.localFields, config.localFields)) ||
24
+ !sameHashes(fileHashMap(portableSnapshot(local, config.localFields)), stateHashMapForConfig(state, config))
25
+ );
22
26
  }
23
27
 
24
28
  export function remoteChangedSinceState(
@@ -30,7 +34,7 @@ export function remoteChangedSinceState(
30
34
  if (!head) return Boolean(state.lastAppliedSnapshot);
31
35
  if (head.snapshotId !== state.lastAppliedSnapshot) return true;
32
36
  if (state.lastRemoteRevision && !sameRevision(head.revision, state.lastRemoteRevision)) return true;
33
- if (syncIncludeChanged(state, config)) return true;
37
+ if (syncPolicyChanged(state, config)) return true;
34
38
  return (
35
39
  includeFromSelectionConfig(config).includes("sessions") && !state.include?.includes("sessions") && head.syncSessions
36
40
  );
@@ -43,7 +47,12 @@ export function hasRemoteChanges(
43
47
  ignoredPaths = new Set<string>(),
44
48
  ) {
45
49
  if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
46
- return !snapshotHashesMatchState(filterSnapshotForConfigPolicy(remote, config), state, config, ignoredPaths);
50
+ return !snapshotHashesMatchState(
51
+ portableSnapshot(filterSnapshotForConfigPolicy(remote, config), remote.localFields),
52
+ state,
53
+ config,
54
+ ignoredPaths,
55
+ );
47
56
  }
48
57
 
49
58
  export function sameHashes(left: Record<string, string>, right: Record<string, string>) {
@@ -87,7 +96,7 @@ function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<stri
87
96
  }
88
97
 
89
98
  export function syncPolicyChanged(state: SyncState, config: SyncPolicyConfig) {
90
- return syncIncludeChanged(state, config);
99
+ return syncIncludeChanged(state, config) || !sameLocalFields(state.localFields, config.localFields);
91
100
  }
92
101
 
93
102
  export function shouldRefreshSyncedState(
@@ -0,0 +1,133 @@
1
+ import path from "node:path";
2
+ export const TEXT_LIMIT = 1024 * 1024;
3
+ export const TEXT_MERGE_WORK_LIMIT = 8_000_000;
4
+ export interface TextMergeBudget {
5
+ remaining: number;
6
+ }
7
+ export interface TextMergeOptions {
8
+ budget?: TextMergeBudget;
9
+ signal?: AbortSignal;
10
+ }
11
+ function charge(budget: TextMergeBudget, work: number) {
12
+ if (work > budget.remaining) throw new Error("Text merge operation complexity bound exceeded.");
13
+ budget.remaining -= work;
14
+ }
15
+ export function isMergeTextPath(filePath: string) {
16
+ return (
17
+ filePath === "AGENTS.md" ||
18
+ (/\.(?:md|txt)$/u.test(filePath) &&
19
+ ["prompts", "skills"].includes(filePath.split("/")[0] ?? "") &&
20
+ path.posix.basename(filePath) !== "keybindings.json")
21
+ );
22
+ }
23
+ export function text(bytes: Buffer) {
24
+ if (bytes.length > TEXT_LIMIT) throw new Error("Text merge bound exceeded.");
25
+ const decoded = bytes.toString("utf8");
26
+ if (
27
+ !Buffer.from(decoded).equals(bytes) ||
28
+ bytes.some((byte) => (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127)
29
+ )
30
+ throw new Error("Unsupported text encoding.");
31
+ return decoded;
32
+ }
33
+ type Hunk = { start: number; end: number; lines: string[] };
34
+ function lines(value: string) {
35
+ return value.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
36
+ }
37
+ function edits(base: string[], next: string[], signal?: AbortSignal): Hunk[] {
38
+ const width = next.length + 1;
39
+ if ((base.length + 1) * width > 4_000_000) throw new Error("Text merge complexity bound exceeded.");
40
+ // Intern once so each charged DP cell compares fixed-size IDs, not long line strings.
41
+ const ids = new Map<string, number>();
42
+ const intern = (line: string) => {
43
+ let id = ids.get(line);
44
+ if (id === undefined) {
45
+ id = ids.size;
46
+ ids.set(line, id);
47
+ }
48
+ return id;
49
+ };
50
+ const baseIds = base.map(intern);
51
+ const nextIds = next.map(intern);
52
+ const grid = new Uint32Array((base.length + 1) * width);
53
+ for (let i = base.length - 1; i >= 0; i--) {
54
+ signal?.throwIfAborted();
55
+ for (let j = next.length - 1; j >= 0; j--)
56
+ grid[i * width + j] =
57
+ baseIds[i] === nextIds[j]
58
+ ? 1 + (grid[(i + 1) * width + j + 1] ?? 0)
59
+ : Math.max(grid[(i + 1) * width + j] ?? 0, grid[i * width + j + 1] ?? 0);
60
+ }
61
+ const result: Hunk[] = [];
62
+ let i = 0;
63
+ let j = 0;
64
+ let active: Hunk | undefined;
65
+ while (i < base.length || j < next.length) {
66
+ if (i < base.length && j < next.length && baseIds[i] === nextIds[j]) {
67
+ if (active) {
68
+ result.push(active);
69
+ active = undefined;
70
+ }
71
+ i++;
72
+ j++;
73
+ continue;
74
+ }
75
+ active ??= { start: i, end: i, lines: [] };
76
+ if (j < next.length && (i === base.length || (grid[i * width + j + 1] ?? 0) >= (grid[(i + 1) * width + j] ?? 0)))
77
+ active.lines.push(next[j++] ?? "");
78
+ else active.end = ++i;
79
+ }
80
+ if (active) result.push(active);
81
+ return result;
82
+ }
83
+ export function mergeText(
84
+ baseBytes: Buffer,
85
+ localBytes: Buffer,
86
+ remoteBytes: Buffer,
87
+ options: TextMergeOptions = {},
88
+ ): Buffer | undefined {
89
+ const budget = options.budget ?? { remaining: TEXT_MERGE_WORK_LIMIT };
90
+ try {
91
+ options.signal?.throwIfAborted();
92
+ const base = lines(text(baseBytes));
93
+ const ours = lines(text(localBytes));
94
+ const theirs = lines(text(remoteBytes));
95
+ const cells = [ours, theirs].map((next) => (base.length + 1) * (next.length + 1));
96
+ if (cells.some((count) => count > 4_000_000)) return;
97
+ charge(budget, (cells[0] ?? 0) + (cells[1] ?? 0));
98
+ const local = edits(base, ours, options.signal);
99
+ const remote = edits(base, theirs, options.signal);
100
+ const combined = [...local];
101
+ for (const right of remote) {
102
+ let same = false;
103
+ for (const left of local) {
104
+ options.signal?.throwIfAborted();
105
+ charge(budget, 1);
106
+ if (left.start === right.start && left.end === right.end && left.lines.join("") === right.lines.join("")) {
107
+ same = true;
108
+ continue;
109
+ }
110
+ const overlap =
111
+ left.start === left.end || right.start === right.end
112
+ ? left.start <= right.end && right.start <= left.end
113
+ : left.start < right.end && right.start < left.end;
114
+ if (overlap) return;
115
+ }
116
+ if (!same) combined.push(right);
117
+ }
118
+ combined.sort((left, right) => left.start - right.start || left.end - right.end);
119
+ let cursor = 0;
120
+ const output: string[] = [];
121
+ for (const edit of combined) {
122
+ output.push(...base.slice(cursor, edit.start), ...edit.lines);
123
+ cursor = edit.end;
124
+ }
125
+ output.push(...base.slice(cursor));
126
+ const result = Buffer.from(output.join(""));
127
+ text(result);
128
+ return result;
129
+ } catch {
130
+ options.signal?.throwIfAborted();
131
+ return;
132
+ }
133
+ }
@@ -75,6 +75,14 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
75
75
  if (ready && !signal.aborted) check.start(ctx, signal, ready);
76
76
  });
77
77
 
78
+ pi.on("agent_start", async (_event, ctx) => {
79
+ await check.interrupt(ctx);
80
+ });
81
+
82
+ pi.on("agent_settled", (_event, ctx) => {
83
+ if (!sessionAbort.signal.aborted) check.settled(ctx);
84
+ });
85
+
78
86
  pi.on("session_shutdown", async (event, ctx) => {
79
87
  sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
80
88
  attention.reset(ctx);
@@ -72,7 +72,11 @@ export async function describeManagerState(
72
72
  };
73
73
  }
74
74
  const configuredTargets = ownRecord(raw.syncSetups);
75
- if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) {
75
+ if (
76
+ (raw.version === 3 || raw.version === 4 || raw.version === 5) &&
77
+ configuredTargets &&
78
+ Object.keys(configuredTargets).length === 0
79
+ ) {
76
80
  return {
77
81
  title: [
78
82
  "Manage sync",
@@ -129,7 +133,14 @@ export async function describeManagerState(
129
133
  `Storage: ${backendStorageDescription(config)}`,
130
134
  `Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
131
135
  `Automatic sync: ${automaticSyncSummary(config.automatic)}`,
136
+ `Automatic transfer at startup: ${config.automaticTransfer ? "On — upload/replace/delete at idle; no reload" : "Off"}`,
132
137
  `Last applied: ${lastAppliedSnapshot}`,
138
+ ...(syncState?.unresolved?.length
139
+ ? [
140
+ `Unresolved: ${syncState.unresolved.length} dependency groups · review History & recovery`,
141
+ `Last observed: ${safeTerminalText(syncState.lastObservedSnapshot ?? "unknown")}`,
142
+ ]
143
+ : []),
133
144
  ...(currentAttention
134
145
  ? [
135
146
  currentAttention.decision.setupName === config.setupName
@@ -40,6 +40,7 @@ export async function showSyncManager(
40
40
  | "setups"
41
41
  | "connections"
42
42
  | "history"
43
+ | "conflicts"
43
44
  | "doctor"
44
45
  | "unlock"
45
46
  | "recover"
@@ -96,6 +97,7 @@ export async function showSyncManager(
96
97
  title: "History & recovery",
97
98
  items: [
98
99
  { id: "history", label: "Browse history", action: "history" },
100
+ { id: "conflicts", label: "Review unresolved conflicts", action: "conflicts" },
99
101
  {
100
102
  id: "doctor",
101
103
  label: "Check setup",
@@ -232,6 +234,14 @@ export async function showSyncManager(
232
234
  await runRoute("init");
233
235
  return { kind: "stay" };
234
236
  },
237
+ conflicts: async () => {
238
+ const result = await runCancellableOperation(ctx, "Reviewing private conflicts…", "conflicts", runRoute, {
239
+ commitAware: true,
240
+ signal: sessionSignal,
241
+ });
242
+ const disposition = await dispatchManagerResult(ctx, result, "sync", runRoute, sessionSignal);
243
+ return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
244
+ },
235
245
  back: async () => ({ kind: "back" }),
236
246
  },
237
247
  });
@@ -0,0 +1,28 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { runDocumentReview } from "@narumitw/pi-tui-kit";
3
+
4
+ /** Cell-exact scrolling in TUI and paginated observable confirmation in RPC. */
5
+ export async function confirmMergeReview(
6
+ ctx: ExtensionCommandContext | ExtensionContext,
7
+ title: string,
8
+ content: string,
9
+ signal: AbortSignal | undefined,
10
+ isCurrent: () => boolean,
11
+ confirmationLabel = "Apply merged transfer",
12
+ ) {
13
+ const result = await runDocumentReview(ctx, {
14
+ title,
15
+ content,
16
+ format: { kind: "text" },
17
+ viewportSize: "adaptive",
18
+ confirmation: { label: confirmationLabel },
19
+ hint: "close",
20
+ signal,
21
+ isCurrent,
22
+ onError: () => {},
23
+ });
24
+ if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
25
+ if (result.kind === "unsupported")
26
+ throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
27
+ return result.kind === "confirmed";
28
+ }
@@ -239,6 +239,7 @@ async function showSelectionDifference(
239
239
  setupName: state.decision.setupName,
240
240
  include: [...state.decision.remoteInclude],
241
241
  automatic: review.config.automatic,
242
+ automaticTransfer: review.config.automaticTransfer,
242
243
  onSwitch: review.config.onSwitch,
243
244
  showStatus: review.config.showStatus,
244
245
  };
@@ -4,12 +4,14 @@ import { loadConfig } from "../settings/config.js";
4
4
  import { localConfigPath } from "../settings/config-file.js";
5
5
  import { updateSyncSetup } from "../settings/settings-management.js";
6
6
  import { updateLocalConfig } from "../settings/settings-store.js";
7
+ import { normalizeLocalFields, sameLocalFields } from "../sync/local-fields.js";
7
8
  import {
8
9
  SETUP_SWITCH_ACTION_OPTIONS,
9
10
  saveOnSwitch,
10
11
  setupSwitchActionFromLabel,
11
12
  setupSwitchActionLabel,
12
13
  } from "../sync/setup-switch.js";
14
+ import { captureMutationOwner } from "../sync/sync-local.js";
13
15
  import type { RunRoute } from "./cancellable-operation.js";
14
16
  import { dispatchManagerResult } from "./manager-result-dispatcher.js";
15
17
  import { AUTOMATIC_SYNC_DESCRIPTION } from "./setup/setup-prompts.js";
@@ -30,7 +32,17 @@ export async function showSyncSettings(
30
32
  const initial = await loadConfig();
31
33
  if (signal?.aborted) return;
32
34
  const setupName = initial.setupName;
33
- type Action = "automatic" | "skip-secret-scan" | "show-status" | "on-switch" | "include" | "remote-include";
35
+ type Action =
36
+ | "automatic"
37
+ | "automatic-transfer"
38
+ | "merge-settings"
39
+ | "local-fields"
40
+ | "content-policy"
41
+ | "skip-secret-scan"
42
+ | "show-status"
43
+ | "on-switch"
44
+ | "include"
45
+ | "remote-include";
34
46
  const menu = defineMenu<Awaited<ReturnType<typeof loadConfig>>, "settings", Action, ExtensionCommandContext>({
35
47
  start: "settings",
36
48
  screens: {
@@ -87,10 +99,158 @@ export async function showSyncSettings(
87
99
  currentValue: "Review",
88
100
  action: "remote-include",
89
101
  },
102
+ {
103
+ id: "automaticTransfer",
104
+ label: "Automatic transfer at startup",
105
+ description:
106
+ "May upload, replace, or delete selected files once at idle startup in TUI/RPC. Requires a baseline and conditional/lease publication; never reloads resources. Turning off cancels pending work.",
107
+ currentValue: state.automaticTransfer ? "On" : "Off",
108
+ values: ["On", "Off"],
109
+ action: "automatic-transfer",
110
+ },
111
+ {
112
+ id: "mergeSettings",
113
+ label: "Settings field merge (experimental)",
114
+ description:
115
+ "Combine independent global settings.json fields using a verified private ancestor; arrays and nested objects remain atomic. No reload.",
116
+ currentValue: state.mergeSettings ? "On" : "Off",
117
+ values: ["On", "Off"],
118
+ action: "merge-settings",
119
+ },
120
+ {
121
+ id: "localFields",
122
+ label: "Machine-local settings fields",
123
+ description:
124
+ "Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
125
+ currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
126
+ action: "local-fields",
127
+ },
128
+ {
129
+ id: "contentPolicy",
130
+ label: "Content / partial sync (experimental)",
131
+ description:
132
+ "Version-5 opt-in: bounded text and prefix-only sessions; partial progress keeps full withheld versions and old baseline hashes.",
133
+ currentValue: state.mergeContent
134
+ ? state.partialSync
135
+ ? "Content & partial"
136
+ : "Content only"
137
+ : state.partialSync
138
+ ? "Partial only"
139
+ : "Off",
140
+ values: ["Off", "Content only", "Content & partial", "Partial only"],
141
+ action: "content-policy",
142
+ },
90
143
  ],
91
144
  }),
92
145
  },
93
146
  actions: {
147
+ "content-policy": async ({ value, signal: actionSignal }) => {
148
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
149
+ const validate = captureMutationOwner(ctx, mutationSignal);
150
+ try {
151
+ const previous = await loadConfig(setupName);
152
+ validate();
153
+ const mergeContent = value === "Content only" || value === "Content & partial";
154
+ const partialSync = value === "Partial only" || value === "Content & partial";
155
+ if (
156
+ !(await ctx.ui.confirm(
157
+ "Save experimental content policy?",
158
+ "This explicitly upgrades settings to version 5; partial snapshots use version 3 and older clients must refuse them. Text/session merge is conservative. Partial sync preserves withheld versions and old baselines. Disabling does not clear unresolved groups or recovery evidence. Review a directional migration first if portable policy changes.",
159
+ { signal: mutationSignal },
160
+ ))
161
+ )
162
+ return { kind: "rejected" };
163
+ validate();
164
+ await updateLocalConfig((current) => {
165
+ validate();
166
+ const setup = current.syncSetups[setupName];
167
+ if (
168
+ !setup ||
169
+ Boolean(setup.sync.mergeContent) !== Boolean(previous.mergeContent) ||
170
+ Boolean(setup.sync.partialSync) !== Boolean(previous.partialSync)
171
+ )
172
+ throw new Error("Content policy changed during review.");
173
+ return {
174
+ ...current,
175
+ version: 5,
176
+ syncSetups: {
177
+ ...current.syncSetups,
178
+ [setupName]: { ...setup, sync: { ...setup.sync, mergeContent, partialSync } },
179
+ },
180
+ };
181
+ }, mutationSignal);
182
+ validate();
183
+ return { kind: "stay" };
184
+ } catch (error) {
185
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
186
+ return { kind: "rejected" };
187
+ }
188
+ },
189
+ "merge-settings": async ({ value, signal: actionSignal }) => {
190
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
191
+ const validate = captureMutationOwner(ctx, mutationSignal);
192
+ try {
193
+ validate();
194
+ await updateSyncSetup(
195
+ setupName,
196
+ (setup) => ({ ...setup, sync: { ...setup.sync, mergeSettings: value === "On" } }),
197
+ { signal: mutationSignal },
198
+ );
199
+ validate();
200
+ return { kind: "stay" };
201
+ } catch (error) {
202
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
203
+ return { kind: "rejected" };
204
+ }
205
+ },
206
+ "local-fields": async ({ signal: actionSignal }) => {
207
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
208
+ const validate = captureMutationOwner(ctx, mutationSignal);
209
+ try {
210
+ const previous = await loadConfig(setupName);
211
+ validate();
212
+ const input = await ctx.ui.input(
213
+ "Machine-local settings.json root fields (JSON string array)",
214
+ JSON.stringify(previous.localFields ?? []),
215
+ { signal: mutationSignal },
216
+ );
217
+ validate();
218
+ if (input === undefined) return { kind: "rejected" };
219
+ let fields: string[];
220
+ try {
221
+ fields = normalizeLocalFields(JSON.parse(input));
222
+ } catch {
223
+ throw new Error("Invalid localFields JSON array; no policy was changed.");
224
+ }
225
+ if (sameLocalFields(previous.localFields, fields)) return { kind: "stay" };
226
+ const confirmed = await ctx.ui.confirm(
227
+ "Save portable field policy?",
228
+ "This opts into settings/snapshot version 4/2; older clients must refuse them. Future snapshots omit these fields, but old remote history is NOT erased. Review an explicit force push/pull migration before further sync. Removed rules can expose or replace local-only values.",
229
+ { signal: mutationSignal },
230
+ );
231
+ validate();
232
+ if (!confirmed) return { kind: "rejected" };
233
+ await updateLocalConfig((current) => {
234
+ validate();
235
+ const setup = current.syncSetups[setupName];
236
+ if (!setup || !sameLocalFields(setup.sync.localFields, previous.localFields))
237
+ throw new Error("Field policy changed while under review; reopen settings.");
238
+ return {
239
+ ...current,
240
+ version: current.version === 5 ? 5 : 4,
241
+ syncSetups: {
242
+ ...current.syncSetups,
243
+ [setupName]: { ...setup, sync: { ...setup.sync, localFields: fields } },
244
+ },
245
+ };
246
+ }, mutationSignal);
247
+ validate();
248
+ return { kind: "stay" };
249
+ } catch (error) {
250
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
251
+ return { kind: "rejected" };
252
+ }
253
+ },
94
254
  automatic: async ({ value, signal: actionSignal }) => {
95
255
  const automatic = value === "On";
96
256
  const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
@@ -112,6 +272,24 @@ export async function showSyncSettings(
112
272
  return { kind: "rejected" };
113
273
  }
114
274
  },
275
+ "automatic-transfer": async ({ value, signal: actionSignal }) => {
276
+ const automaticTransfer = value === "On";
277
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
278
+ try {
279
+ await updateSyncSetup(setupName, (setup) => ({ ...setup, sync: { ...setup.sync, automaticTransfer } }), {
280
+ signal: mutationSignal,
281
+ });
282
+ if (mutationSignal.aborted) return { kind: "rejected" };
283
+ ctx.ui.notify(
284
+ `Automatic startup transfer ${automaticTransfer ? "enabled for the next session start" : "disabled"}. Completed transfers are not undone.`,
285
+ "info",
286
+ );
287
+ return { kind: "stay" };
288
+ } catch (error) {
289
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
290
+ return { kind: "rejected" };
291
+ }
292
+ },
115
293
  "skip-secret-scan": async ({ value, signal: actionSignal }) => {
116
294
  const skipSecretScan = value === "On";
117
295
  const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
@@ -192,5 +370,8 @@ export async function showSyncSettings(
192
370
  }
193
371
 
194
372
  function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown) {
195
- ctx.ui.notify(`Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`, "error");
373
+ ctx.ui.notify(
374
+ `Pi Sync settings save failed: ${safeTerminalText(error instanceof Error ? error.message : String(error))}`,
375
+ "error",
376
+ );
196
377
  }
@@ -40,7 +40,7 @@ async function showAddTarget(ctx: ExtensionCommandContext, signal?: AbortSignal)
40
40
  let raw = await readLocalConfigObject();
41
41
  if (signal?.aborted) return;
42
42
  if (!raw) return void ctx.ui.notify("Set up the first sync setup before adding another.", "info");
43
- if (raw.version !== 3) {
43
+ if (raw.version !== 3 && raw.version !== 4 && raw.version !== 5) {
44
44
  ctx.ui.notify("Version 1 and version 2 settings are unsupported and are never migrated.", "error");
45
45
  return;
46
46
  }
@@ -88,7 +88,7 @@ async function showEditTarget(ctx: ExtensionCommandContext, name: string, signal
88
88
  const partial = await loadPartialConfig(name);
89
89
  if (signal?.aborted) return;
90
90
  if (!partial.setupName) {
91
- ctx.ui.notify("Create version 3 settings before editing a named sync setup.", "info");
91
+ ctx.ui.notify("Create supported pi-sync settings before editing a named sync setup.", "info");
92
92
  return;
93
93
  }
94
94
  if (partial.storageKind === "webdav") {
@@ -78,7 +78,7 @@ export async function showSetupSwitcher(
78
78
  ) {
79
79
  const raw = await readLocalConfigObject();
80
80
  if (signal?.aborted) return false;
81
- if (raw?.version !== 3) {
81
+ if (raw?.version !== 3 && raw?.version !== 4 && raw?.version !== 5) {
82
82
  ctx.ui.notify("Add a second sync setup before switching setups.", "info");
83
83
  return false;
84
84
  }
@@ -128,6 +128,7 @@ export async function showSetupSwitcher(
128
128
  `Storage: ${backendStorageDescription(config)}`,
129
129
  `Included content: ${config.include.length} paths`,
130
130
  `Automatic sync: ${automaticSyncSummary(config.automatic)} · Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`,
131
+ `Automatic transfer at startup: ${config.automaticTransfer ? "On — may upload, replace, or delete selected files; no reload" : "Off"}`,
131
132
  "",
132
133
  switchEffect,
133
134
  ].join("\n"),
@@ -31,10 +31,10 @@ export async function showStorageConnections(ctx: ExtensionCommandContext, signa
31
31
  return {
32
32
  kind: "actions",
33
33
  title: "Storage connections",
34
- lines: state.version3
34
+ lines: state.supportedVersion
35
35
  ? ["Server addresses and sign-in details, reusable across sync setups."]
36
- : ["Create version 3 settings before managing storage connections."],
37
- items: state.version3
36
+ : ["Create supported pi-sync settings before managing storage connections."],
37
+ items: state.supportedVersion
38
38
  ? [
39
39
  { id: "add", label: "Add storage connection", action: "add" as const },
40
40
  ...names.map((name, index) => {
@@ -136,11 +136,15 @@ async function loadStorageMenuState(selectedName: string | undefined, signal?: A
136
136
  const profiles = ownRecord(raw?.storageConnections) ?? {};
137
137
  const profile = selectedName ? ownRecord(profiles[selectedName]) : undefined;
138
138
  if (!selectedName || !profile) {
139
- return { version3: raw?.version === 3, profiles, selected: undefined };
139
+ return {
140
+ supportedVersion: raw?.version === 3 || raw?.version === 4 || raw?.version === 5,
141
+ profiles,
142
+ selected: undefined,
143
+ };
140
144
  }
141
145
  const usedBy = referencingSetups(raw, selectedName);
142
146
  return {
143
- version3: raw?.version === 3,
147
+ supportedVersion: raw?.version === 3 || raw?.version === 4 || raw?.version === 5,
144
148
  profiles,
145
149
  selected: {
146
150
  name: selectedName,
@@ -175,7 +179,7 @@ async function editStorageConnection(
175
179
  ) {
176
180
  if (ctx.mode !== "tui") {
177
181
  ctx.ui.notify(
178
- "Editing storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
182
+ "Editing storage connections requires TUI mode for safe credential handling. Edit the private pi-sync settings file instead.",
179
183
  "warning",
180
184
  );
181
185
  return;
@@ -250,7 +254,7 @@ async function editStorageConnection(
250
254
  export async function showAddStorageConnection(ctx: ExtensionCommandContext, signal?: AbortSignal) {
251
255
  if (ctx.mode !== "tui") {
252
256
  ctx.ui.notify(
253
- "Adding storage connections requires TUI mode for safe credential handling. Edit the private version 3 settings file instead.",
257
+ "Adding storage connections requires TUI mode for safe credential handling. Edit the private pi-sync settings file instead.",
254
258
  "warning",
255
259
  );
256
260
  return false;
@@ -153,6 +153,8 @@ export function observationSummary(observation: StartupObservation) {
153
153
  if (result.firstSync)
154
154
  return result.head ? "No sync baseline; review remote and local content" : "Remote empty; no sync baseline";
155
155
  if (!result.head) return "Remote snapshot missing; review needed";
156
+ if (observation.automaticTransfer && result.selectionState?.kind === "legacy")
157
+ return "Legacy remote has no authoritative content list; review /sync and choose an explicit direction to adopt policy";
156
158
  if (result.localChanged && result.remoteChanged) return "Local and remote changed since last sync; review needed";
157
159
  if (result.remoteChanged) return "Remote changed since last sync";
158
160
  if (result.localChanged) return "Local content changed since last sync";
@@ -171,6 +173,7 @@ export function classifyObservation(observation: StartupObservation): Observatio
171
173
  if (result.selectionState?.kind === "different") return "review";
172
174
  if (result.firstSync) return result.head ? "review" : "guidance";
173
175
  if (!result.head || (result.localChanged && result.remoteChanged)) return "review";
176
+ if (observation.automaticTransfer && result.selectionState?.kind === "legacy") return "review";
174
177
  if (result.localChanged || result.remoteChanged) return "status";
175
178
  // Legacy metadata alone requires no scope confirmation in transfer operations.
176
179
  return "none";