@narumitw/pi-sync 0.57.10 → 0.57.11

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 (147) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -1
  3. package/dist/chunks/{agent-candidate-EYB35AGL.ts → agent-candidate-OCDPRI5G.ts} +11 -11
  4. package/dist/chunks/{agent-plan-KO43GTOR.ts → agent-plan-P5B5RGZ7.ts} +14 -14
  5. package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts → agent-refusal-retry-476YGNU5.ts} +10 -10
  6. package/dist/chunks/{agent-store-UT52DS7B.ts → agent-store-RJSMNOYP.ts} +7 -7
  7. package/dist/chunks/{agent-sync-VM3TXWA3.ts → agent-sync-EUK2CPSS.ts} +6 -6
  8. package/dist/chunks/{chunk-UCD7TQDT.ts → chunk-3IYAPMST.ts} +4 -4
  9. package/dist/chunks/{chunk-JXWTEJS5.ts → chunk-45COPAK7.ts} +10 -3
  10. package/dist/chunks/chunk-45COPAK7.ts.map +7 -0
  11. package/dist/chunks/{chunk-DOJRPBQU.ts → chunk-5TSCBBPP.ts} +4 -4
  12. package/dist/chunks/{chunk-IMMMZUFC.ts → chunk-67INJPKR.ts} +4 -4
  13. package/dist/chunks/{chunk-CDQGSZL3.ts → chunk-6LD5KX55.ts} +6 -6
  14. package/dist/chunks/{chunk-MDUORMCK.ts → chunk-6O3EDBRL.ts} +2 -2
  15. package/dist/chunks/{chunk-AAPAHANE.ts → chunk-7A35ZAVO.ts} +320 -219
  16. package/dist/chunks/chunk-7A35ZAVO.ts.map +7 -0
  17. package/dist/chunks/{chunk-2VSN3MFX.ts → chunk-7OLLPPLU.ts} +29 -23
  18. package/dist/chunks/chunk-7OLLPPLU.ts.map +7 -0
  19. package/dist/chunks/{chunk-STVQQ5KG.ts → chunk-7ZIYU5QS.ts} +6 -6
  20. package/dist/chunks/{chunk-AO6EDUA6.ts → chunk-AKMV47YC.ts} +3 -3
  21. package/dist/chunks/{chunk-66GAUVDL.ts → chunk-BEST45H2.ts} +6 -6
  22. package/dist/chunks/{chunk-DO55XUNU.ts → chunk-CBLTWXAU.ts} +15 -5
  23. package/dist/chunks/chunk-CBLTWXAU.ts.map +7 -0
  24. package/dist/chunks/{chunk-PWA3YN5Z.ts → chunk-CPT2BN6N.ts} +2 -2
  25. package/dist/chunks/{chunk-73MDIVYJ.ts → chunk-DN4IA7MR.ts} +6 -6
  26. package/dist/chunks/{chunk-4RLATUP4.ts → chunk-DRTC4PKM.ts} +6 -6
  27. package/dist/chunks/{chunk-PHANBTVM.ts → chunk-ESKOPQVB.ts} +4 -4
  28. package/dist/chunks/{chunk-ODF2M4RF.ts → chunk-F7E3VFXU.ts} +5 -5
  29. package/dist/chunks/{chunk-YZ5BQNRZ.ts → chunk-FHE2DCA2.ts} +2 -2
  30. package/dist/chunks/{chunk-YZ5BQNRZ.ts.map → chunk-FHE2DCA2.ts.map} +2 -2
  31. package/dist/chunks/{chunk-MRIPZGPR.ts → chunk-FLHCOJCD.ts} +3 -3
  32. package/dist/chunks/{chunk-5WJQ6XOW.ts → chunk-GNYGNHFF.ts} +2 -2
  33. package/dist/chunks/{chunk-DFJGTVPX.ts → chunk-KYTT7KV4.ts} +2 -2
  34. package/dist/chunks/{chunk-UGSSZWQ6.ts → chunk-MKSMJPVA.ts} +2 -2
  35. package/dist/chunks/{chunk-RAMHGOX2.ts → chunk-OJARL2TG.ts} +4 -4
  36. package/dist/chunks/{chunk-BJXSCGQK.ts → chunk-OKPPZA4D.ts} +148 -89
  37. package/dist/chunks/chunk-OKPPZA4D.ts.map +7 -0
  38. package/dist/chunks/{chunk-RX3WGWVL.ts → chunk-OVBAW7KV.ts} +177 -130
  39. package/dist/chunks/chunk-OVBAW7KV.ts.map +7 -0
  40. package/dist/chunks/{chunk-U77DWNRH.ts → chunk-Q23JJW7E.ts} +3 -3
  41. package/dist/chunks/{chunk-QWV3XKNJ.ts → chunk-TIMWN2RH.ts} +5 -5
  42. package/dist/chunks/{chunk-Z5O2TLNF.ts → chunk-TZC7GDJE.ts} +7 -7
  43. package/dist/chunks/{chunk-TO2GMX3K.ts → chunk-XJHQPB3E.ts} +2 -2
  44. package/dist/chunks/{chunk-JY2RVOUL.ts → chunk-Y4UVXOLQ.ts} +5 -5
  45. package/dist/chunks/{chunk-M5IV3KL2.ts → chunk-ZDDBZH72.ts} +3 -3
  46. package/dist/chunks/{command-handler-FO6F2APH.ts → command-handler-F2E7A5T5.ts} +22 -17
  47. package/dist/chunks/command-handler-F2E7A5T5.ts.map +7 -0
  48. package/dist/chunks/{config-B37W7FXW.ts → config-HYG7G5EM.ts} +6 -4
  49. package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts → conflict-artifacts-J6WSDCWP.ts} +12 -12
  50. package/dist/chunks/{conflict-review-BQSHSJB3.ts → conflict-review-2QMO2K6Q.ts} +23 -23
  51. package/dist/chunks/{continuous-record-4DQ2EAZS.ts → continuous-record-AH7JGYKM.ts} +6 -6
  52. package/dist/chunks/{continuous-sync-ZT3UEQLK.ts → continuous-sync-VRMXCA5X.ts} +18 -18
  53. package/dist/chunks/{file-selection-FBLKM25V.ts → file-selection-IELTXSGZ.ts} +6 -7
  54. package/dist/chunks/{file-selection-FBLKM25V.ts.map → file-selection-IELTXSGZ.ts.map} +1 -1
  55. package/dist/chunks/{git-backend-7PCWECRN.ts → git-backend-KHD36W27.ts} +6 -7
  56. package/dist/chunks/{git-backend-7PCWECRN.ts.map → git-backend-KHD36W27.ts.map} +1 -1
  57. package/dist/chunks/{local-fields-B6JWLPAE.ts → local-fields-JEKAPZKD.ts} +6 -7
  58. package/dist/chunks/manager-result-dispatcher-6W52NM7F.ts +32 -0
  59. package/dist/chunks/{manager-ui-DGRTG76I.ts → manager-ui-LM7DSJ2Y.ts} +103 -52
  60. package/dist/chunks/manager-ui-LM7DSJ2Y.ts.map +7 -0
  61. package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts → merge-baseline-store-65GKUMXI.ts} +6 -6
  62. package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts → remote-selection-ui-TD4C4W3J.ts} +16 -12
  63. package/dist/chunks/{s3-backend-CJXBEWWU.ts → s3-backend-L24UZ6IK.ts} +12 -10
  64. package/dist/chunks/s3-backend-L24UZ6IK.ts.map +7 -0
  65. package/dist/chunks/{setup-switch-66GWMBNH.ts → setup-switch-PIPXYPCW.ts} +5 -5
  66. package/dist/chunks/{setup-wizard-OHZXJVQZ.ts → setup-wizard-RR5U3SUB.ts} +6 -6
  67. package/dist/chunks/{snapshot-VRHNAUUH.ts → snapshot-RGNDYT6A.ts} +5 -6
  68. package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts → snapshot-transaction-26BQO36S.ts} +8 -8
  69. package/dist/chunks/{sync-inspection-4WFQEJOE.ts → sync-inspection-MOMLGUTD.ts} +11 -11
  70. package/dist/chunks/{sync-operations-X4AOXMBT.ts → sync-operations-CV3TULLD.ts} +25 -25
  71. package/dist/chunks/{sync-state-SWLSMK4G.ts → sync-state-7L4NT7W2.ts} +7 -8
  72. package/dist/chunks/{webdav-backend-2Q5ZEBST.ts → webdav-backend-3ZCACILP.ts} +7 -8
  73. package/dist/chunks/{webdav-backend-2Q5ZEBST.ts.map → webdav-backend-3ZCACILP.ts.map} +1 -1
  74. package/dist/index.ts +19 -19
  75. package/package.json +1 -1
  76. package/skills/configure-pi-sync/references/configuration.md +8 -1
  77. package/src/backends/s3/s3-client.ts +8 -1
  78. package/src/commands/command-execution.ts +8 -3
  79. package/src/paths.ts +6 -1
  80. package/src/settings/config-file.ts +205 -118
  81. package/src/settings/config.ts +1 -1
  82. package/src/settings/settings-management.ts +15 -1
  83. package/src/settings/settings-store.ts +0 -14
  84. package/src/snapshot/session-paths.ts +15 -2
  85. package/src/snapshot/snapshot-mutation-queues.ts +142 -0
  86. package/src/snapshot/snapshot-transaction.ts +122 -125
  87. package/src/sync/merge-apply.ts +6 -0
  88. package/src/ui/cancellable-operation.ts +36 -13
  89. package/src/ui/manager-state.ts +3 -1
  90. package/src/ui/manager-ui.ts +76 -24
  91. package/src/ui/remote-selection-ui.ts +131 -82
  92. package/src/ui/settings-ui.ts +2 -1
  93. package/dist/chunks/chunk-2VSN3MFX.ts.map +0 -7
  94. package/dist/chunks/chunk-AAPAHANE.ts.map +0 -7
  95. package/dist/chunks/chunk-BJXSCGQK.ts.map +0 -7
  96. package/dist/chunks/chunk-DO55XUNU.ts.map +0 -7
  97. package/dist/chunks/chunk-JXWTEJS5.ts.map +0 -7
  98. package/dist/chunks/chunk-RX3WGWVL.ts.map +0 -7
  99. package/dist/chunks/command-handler-FO6F2APH.ts.map +0 -7
  100. package/dist/chunks/manager-result-dispatcher-NODX3KKH.ts +0 -28
  101. package/dist/chunks/manager-ui-DGRTG76I.ts.map +0 -7
  102. package/dist/chunks/s3-backend-CJXBEWWU.ts.map +0 -7
  103. /package/dist/chunks/{agent-candidate-EYB35AGL.ts.map → agent-candidate-OCDPRI5G.ts.map} +0 -0
  104. /package/dist/chunks/{agent-plan-KO43GTOR.ts.map → agent-plan-P5B5RGZ7.ts.map} +0 -0
  105. /package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts.map → agent-refusal-retry-476YGNU5.ts.map} +0 -0
  106. /package/dist/chunks/{agent-store-UT52DS7B.ts.map → agent-store-RJSMNOYP.ts.map} +0 -0
  107. /package/dist/chunks/{agent-sync-VM3TXWA3.ts.map → agent-sync-EUK2CPSS.ts.map} +0 -0
  108. /package/dist/chunks/{chunk-UCD7TQDT.ts.map → chunk-3IYAPMST.ts.map} +0 -0
  109. /package/dist/chunks/{chunk-DOJRPBQU.ts.map → chunk-5TSCBBPP.ts.map} +0 -0
  110. /package/dist/chunks/{chunk-IMMMZUFC.ts.map → chunk-67INJPKR.ts.map} +0 -0
  111. /package/dist/chunks/{chunk-CDQGSZL3.ts.map → chunk-6LD5KX55.ts.map} +0 -0
  112. /package/dist/chunks/{chunk-MDUORMCK.ts.map → chunk-6O3EDBRL.ts.map} +0 -0
  113. /package/dist/chunks/{chunk-STVQQ5KG.ts.map → chunk-7ZIYU5QS.ts.map} +0 -0
  114. /package/dist/chunks/{chunk-AO6EDUA6.ts.map → chunk-AKMV47YC.ts.map} +0 -0
  115. /package/dist/chunks/{chunk-66GAUVDL.ts.map → chunk-BEST45H2.ts.map} +0 -0
  116. /package/dist/chunks/{chunk-PWA3YN5Z.ts.map → chunk-CPT2BN6N.ts.map} +0 -0
  117. /package/dist/chunks/{chunk-73MDIVYJ.ts.map → chunk-DN4IA7MR.ts.map} +0 -0
  118. /package/dist/chunks/{chunk-4RLATUP4.ts.map → chunk-DRTC4PKM.ts.map} +0 -0
  119. /package/dist/chunks/{chunk-PHANBTVM.ts.map → chunk-ESKOPQVB.ts.map} +0 -0
  120. /package/dist/chunks/{chunk-ODF2M4RF.ts.map → chunk-F7E3VFXU.ts.map} +0 -0
  121. /package/dist/chunks/{chunk-MRIPZGPR.ts.map → chunk-FLHCOJCD.ts.map} +0 -0
  122. /package/dist/chunks/{chunk-5WJQ6XOW.ts.map → chunk-GNYGNHFF.ts.map} +0 -0
  123. /package/dist/chunks/{chunk-DFJGTVPX.ts.map → chunk-KYTT7KV4.ts.map} +0 -0
  124. /package/dist/chunks/{chunk-UGSSZWQ6.ts.map → chunk-MKSMJPVA.ts.map} +0 -0
  125. /package/dist/chunks/{chunk-RAMHGOX2.ts.map → chunk-OJARL2TG.ts.map} +0 -0
  126. /package/dist/chunks/{chunk-U77DWNRH.ts.map → chunk-Q23JJW7E.ts.map} +0 -0
  127. /package/dist/chunks/{chunk-QWV3XKNJ.ts.map → chunk-TIMWN2RH.ts.map} +0 -0
  128. /package/dist/chunks/{chunk-Z5O2TLNF.ts.map → chunk-TZC7GDJE.ts.map} +0 -0
  129. /package/dist/chunks/{chunk-TO2GMX3K.ts.map → chunk-XJHQPB3E.ts.map} +0 -0
  130. /package/dist/chunks/{chunk-JY2RVOUL.ts.map → chunk-Y4UVXOLQ.ts.map} +0 -0
  131. /package/dist/chunks/{chunk-M5IV3KL2.ts.map → chunk-ZDDBZH72.ts.map} +0 -0
  132. /package/dist/chunks/{config-B37W7FXW.ts.map → config-HYG7G5EM.ts.map} +0 -0
  133. /package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts.map → conflict-artifacts-J6WSDCWP.ts.map} +0 -0
  134. /package/dist/chunks/{conflict-review-BQSHSJB3.ts.map → conflict-review-2QMO2K6Q.ts.map} +0 -0
  135. /package/dist/chunks/{continuous-record-4DQ2EAZS.ts.map → continuous-record-AH7JGYKM.ts.map} +0 -0
  136. /package/dist/chunks/{continuous-sync-ZT3UEQLK.ts.map → continuous-sync-VRMXCA5X.ts.map} +0 -0
  137. /package/dist/chunks/{local-fields-B6JWLPAE.ts.map → local-fields-JEKAPZKD.ts.map} +0 -0
  138. /package/dist/chunks/{manager-result-dispatcher-NODX3KKH.ts.map → manager-result-dispatcher-6W52NM7F.ts.map} +0 -0
  139. /package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts.map → merge-baseline-store-65GKUMXI.ts.map} +0 -0
  140. /package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts.map → remote-selection-ui-TD4C4W3J.ts.map} +0 -0
  141. /package/dist/chunks/{setup-switch-66GWMBNH.ts.map → setup-switch-PIPXYPCW.ts.map} +0 -0
  142. /package/dist/chunks/{setup-wizard-OHZXJVQZ.ts.map → setup-wizard-RR5U3SUB.ts.map} +0 -0
  143. /package/dist/chunks/{snapshot-VRHNAUUH.ts.map → snapshot-RGNDYT6A.ts.map} +0 -0
  144. /package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts.map → snapshot-transaction-26BQO36S.ts.map} +0 -0
  145. /package/dist/chunks/{sync-inspection-4WFQEJOE.ts.map → sync-inspection-MOMLGUTD.ts.map} +0 -0
  146. /package/dist/chunks/{sync-operations-X4AOXMBT.ts.map → sync-operations-CV3TULLD.ts.map} +0 -0
  147. /package/dist/chunks/{sync-state-SWLSMK4G.ts.map → sync-state-7L4NT7W2.ts.map} +0 -0
@@ -31,6 +31,15 @@ export async function showSyncManager(
31
31
  await runRoute("help");
32
32
  return;
33
33
  }
34
+ const validateOwner = captureMutationOwner(ctx, sessionSignal);
35
+ const isCurrent = () => {
36
+ try {
37
+ validateOwner();
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
34
43
  type Screen = "main";
35
44
  type Action =
36
45
  | "review-attention"
@@ -77,10 +86,11 @@ export async function showSyncManager(
77
86
  },
78
87
  },
79
88
  actions: {
80
- "review-attention": async () => {
81
- const attention = options.getAttention?.();
89
+ "review-attention": async ({ state }) => {
90
+ const attention = state.manager.attention;
91
+ if (attention && options.getAttention?.() !== attention) return { kind: "stay" };
82
92
  if (!attention) {
83
- const observation = options.getObservation?.();
93
+ const observation = state.manager.observation;
84
94
  if (!observation) return { kind: "stay" };
85
95
  // A head-only observation is not an authoritative decision. Load a fresh
86
96
  // snapshot only after the user activates the existing review flow.
@@ -113,24 +123,21 @@ export async function showSyncManager(
113
123
  );
114
124
  return { kind: disposition };
115
125
  },
116
- sync: async () => {
126
+ sync: async ({ state }) => {
117
127
  const pendingAttention = options.getAttention?.();
118
- if (pendingAttention) {
119
- const activeConfig = await loadConfig();
120
- if (sessionSignal?.aborted) return { kind: "close" };
121
- if (pendingAttention.decision.setupName === activeConfig.setupName) {
122
- ctx.ui.notify("Review synced content before starting Sync now.", "warning");
123
- return { kind: "stay" };
124
- }
128
+ if (pendingAttention?.decision.setupName === state.manager.configReview?.setupName) {
129
+ ctx.ui.notify("Review synced content before starting Sync now.", "warning");
130
+ return { kind: "stay" };
125
131
  }
126
132
  const result = await runCancellableOperation(ctx, "Checking current sync setup…", "sync", runRoute, {
127
133
  commitAware: true,
128
134
  signal: sessionSignal,
135
+ target: state.manager.configReview?.setupName,
129
136
  });
130
137
  const disposition = await dispatchManagerResult(ctx, result, "sync", runRoute, sessionSignal);
131
138
  return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
132
139
  },
133
- "retry-refusal": async ({ signal: actionSignal }) => {
140
+ "retry-refusal": async ({ state, signal: actionSignal }) => {
134
141
  const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
135
142
  const validate = captureMutationOwner(ctx, signal);
136
143
  validate();
@@ -140,6 +147,7 @@ export async function showSyncManager(
140
147
  commitAware: true,
141
148
  cancelAcrossDialogs: true,
142
149
  signal,
150
+ target: state.manager.configReview?.setupName,
143
151
  cancelledMessage: "Model retry cancelled. Submitted requests and committed writes cannot be undone.",
144
152
  });
145
153
  if (signal.aborted) return { kind: "close" };
@@ -155,23 +163,25 @@ export async function showSyncManager(
155
163
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
156
164
  return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" };
157
165
  },
158
- settings: async () => {
159
- const result = await showSyncSettings(ctx, runRoute, sessionSignal);
166
+ settings: async ({ state }) => {
167
+ const result = await showSyncSettings(ctx, runRoute, sessionSignal, state.manager.configReview?.setupName);
160
168
  return { kind: result === "exit" ? "close" : "stay" };
161
169
  },
162
- pull: async () => {
170
+ pull: async ({ state }) => {
163
171
  const result = await runCancellableOperation(ctx, "Checking remote changes…", "pull", runRoute, {
164
172
  commitAware: true,
165
173
  cancelledMessage: "Pull check cancelled; no local files were changed.",
174
+ target: state.manager.configReview?.setupName,
166
175
  signal: sessionSignal,
167
176
  });
168
177
  const disposition = await dispatchManagerResult(ctx, result, "pull", runRoute, sessionSignal);
169
178
  return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
170
179
  },
171
- push: async () => {
180
+ push: async ({ state }) => {
172
181
  const result = await runCancellableOperation(ctx, "Preparing push preview…", "push", runRoute, {
173
182
  commitAware: true,
174
183
  cancelledMessage: "Push preparation cancelled; no remote files were changed.",
184
+ target: state.manager.configReview?.setupName,
175
185
  signal: sessionSignal,
176
186
  });
177
187
  const disposition = await dispatchManagerResult(ctx, result, "push", runRoute, sessionSignal);
@@ -185,31 +195,34 @@ export async function showSyncManager(
185
195
  await showStorageConnections(ctx, sessionSignal);
186
196
  return { kind: "stay" };
187
197
  },
188
- history: async () => {
198
+ history: async ({ state }) => {
189
199
  const result = await runCancellableOperation(ctx, "Loading sync history…", "history", runRoute, {
190
200
  signal: sessionSignal,
191
201
  commitAware: true,
192
202
  cancelAcrossDialogs: true,
203
+ target: state.manager.configReview?.setupName,
193
204
  cancelledMessage: "History review cancelled; no files were changed.",
194
205
  });
195
206
  return { kind: result.kind === "closed" ? "close" : "stay" };
196
207
  },
197
- diff: async () => {
208
+ diff: async ({ state }) => {
198
209
  const result = await runCancellableOperation(ctx, "Comparing local and remote…", "diff", runRoute, {
210
+ target: state.manager.configReview?.setupName,
199
211
  signal: sessionSignal,
200
212
  });
201
213
  return { kind: result.kind === "closed" ? "close" : "stay" };
202
214
  },
203
- status: async () => {
215
+ status: async ({ state }) => {
204
216
  const result = await runCancellableOperation(ctx, "Checking remote status…", "status", runRoute, {
217
+ target: state.manager.configReview?.setupName,
205
218
  signal: sessionSignal,
206
219
  });
207
220
  return { kind: result.kind === "closed" ? "close" : "stay" };
208
221
  },
209
- doctor: async ({ signal: actionSignal }) => {
222
+ doctor: async ({ state, signal: actionSignal }) => {
210
223
  const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
211
224
  const validate = captureMutationOwner(ctx, signal);
212
- const config = await loadConfig();
225
+ const config = await loadConfig(state.manager.configReview?.setupName);
213
226
  validate();
214
227
  const confirmed = await ctx.ui.confirm(
215
228
  "Run diagnostics?",
@@ -241,11 +254,12 @@ export async function showSyncManager(
241
254
  return { kind: "close" };
242
255
  },
243
256
  init: async () => {
244
- await runRoute("init");
257
+ await runRoute("init", sessionSignal);
245
258
  return { kind: "stay" };
246
259
  },
247
- conflicts: async () => {
260
+ conflicts: async ({ state }) => {
248
261
  const result = await runCancellableOperation(ctx, "Reviewing private conflicts…", "conflicts", runRoute, {
262
+ target: state.manager.configReview?.setupName,
249
263
  commitAware: true,
250
264
  signal: sessionSignal,
251
265
  });
@@ -254,6 +268,44 @@ export async function showSyncManager(
254
268
  },
255
269
  },
256
270
  });
271
+ // Setup-specific actions must use the configuration shown by this screen, not
272
+ // an active setup selected by another process while the menu was open.
273
+ for (const action of [
274
+ "review-attention",
275
+ "sync",
276
+ "retry-refusal",
277
+ "settings",
278
+ "pull",
279
+ "push",
280
+ "history",
281
+ "diff",
282
+ "status",
283
+ "doctor",
284
+ "conflicts",
285
+ ] as const) {
286
+ const activate = menu.actions[action];
287
+ menu.actions[action] = async (invocation) => {
288
+ validateOwner();
289
+ const review = invocation.state.manager.configReview;
290
+ let current = false;
291
+ try {
292
+ const config = await loadConfig();
293
+ validateOwner();
294
+ invocation.signal.throwIfAborted();
295
+ current = review?.fingerprint === syncCheckConfigFingerprint(config);
296
+ } catch {
297
+ if (!isCurrent() || invocation.signal.aborted) return { kind: "close" };
298
+ }
299
+ if (!current) {
300
+ ctx.ui.notify(
301
+ "Sync setup changed while the manager was open. Review the refreshed setup before continuing.",
302
+ "warning",
303
+ );
304
+ return { kind: "stay" };
305
+ }
306
+ return activate(invocation);
307
+ };
308
+ }
257
309
  const menuContext: ExtensionCommandContext = {
258
310
  ...ctx,
259
311
  ui: {
@@ -293,7 +345,7 @@ export async function showSyncManager(
293
345
  return { manager };
294
346
  },
295
347
  signal: sessionSignal,
296
- isCurrent: () => !sessionSignal?.aborted,
348
+ isCurrent,
297
349
  });
298
350
  }
299
351
 
@@ -2,17 +2,13 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
  import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
4
4
  import type { RemoteHead, SyncBackend } from "../backends/sync-backend.js";
5
- import {
6
- loadConfig,
7
- loadPartialConfig,
8
- type SyncSetupStorageReview,
9
- syncConfigReviewFingerprint,
10
- } from "../settings/config.js";
5
+ import { loadConfig, syncCheckConfigFingerprint, syncConfigReviewFingerprint } from "../settings/config.js";
11
6
  import { SyncSetupReviewChangedError, updateSyncSetup } from "../settings/settings-management.js";
12
- import type { AnySyncConfig, PartialConfig } from "../settings/settings-types.js";
7
+ import type { AnySyncConfig } from "../settings/settings-types.js";
13
8
  import { readSnapshotForHead } from "../sync/remote-snapshot.js";
14
9
  import { throwIfAborted } from "../sync/signals.js";
15
10
  import { errorMessage } from "../sync/sync-errors.js";
11
+ import { captureMutationOwner } from "../sync/sync-local.js";
16
12
  import {
17
13
  compareSyncInclude,
18
14
  inspectRemoteSelection,
@@ -20,7 +16,12 @@ import {
20
16
  type RemoteSelectionState,
21
17
  sameSyncInclude,
22
18
  } from "../sync/sync-policy.js";
23
- import { type RunRoute, type RunRouteResult, runCancellableOperation } from "./cancellable-operation.js";
19
+ import {
20
+ type RunRoute,
21
+ type RunRouteResult,
22
+ runCancellableOperation,
23
+ runCancellableTask,
24
+ } from "./cancellable-operation.js";
24
25
  import { setSyncStatus } from "./sync-status.js";
25
26
  import { safeTerminalText } from "./terminal-text.js";
26
27
 
@@ -50,11 +51,23 @@ export async function showRemoteSelectionReview(
50
51
  factory: SyncBackendFactory = createSyncBackend,
51
52
  options: RemoteSelectionReviewOptions = {},
52
53
  ): Promise<RemoteSelectionReviewResult> {
54
+ const validateOwner = captureMutationOwner(ctx, signal);
55
+ const isCurrent = () => isReviewOwnerCurrent(validateOwner);
53
56
  try {
57
+ validateOwner();
54
58
  let decision = options.decision;
55
59
  if (!decision) {
56
- const inspected = await inspectConfiguredRemoteSelection(ctx, setupName, signal, factory);
57
- if (!inspected || signal?.aborted) return { kind: "stale" };
60
+ const inspection = await runCancellableTask(
61
+ ctx,
62
+ "Checking remote content list…",
63
+ (workSignal) => inspectConfiguredRemoteSelection(ctx, setupName, workSignal, factory, validateOwner),
64
+ { signal, isCurrent, cancelledMessage: null },
65
+ );
66
+ if (!isCurrent()) return { kind: "stale" };
67
+ if (inspection.kind !== "completed") return { kind: inspection.kind === "cancelled" ? "back" : "closed" };
68
+ validateOwner();
69
+ const inspected = inspection.value;
70
+ if (!inspected) return { kind: "stale" };
58
71
  // A successful fresh read can supersede an old explicit mismatch without
59
72
  // changing local settings. This is not user cancellation or proof of file equality.
60
73
  if (inspected.kind === "empty" || inspected.state.kind !== "different") {
@@ -95,12 +108,23 @@ export async function showRemoteSelectionReview(
95
108
  signal,
96
109
  factory,
97
110
  options,
111
+ validateOwner,
98
112
  );
99
113
  if (result.kind !== "refresh") return result;
100
- const refreshed = await runWithOptionalStateAccess(options, () =>
101
- inspectConfiguredRemoteSelection(ctx, currentDecision.setupName, signal, factory),
114
+ const refresh = await runCancellableTask(
115
+ ctx,
116
+ "Refreshing remote content list…",
117
+ (workSignal) =>
118
+ runWithOptionalStateAccess(options, () =>
119
+ inspectConfiguredRemoteSelection(ctx, currentDecision.setupName, workSignal, factory, validateOwner),
120
+ ),
121
+ { signal, isCurrent, cancelledMessage: null },
102
122
  );
103
- if (!refreshed || signal?.aborted) return { kind: "stale" };
123
+ if (!isCurrent()) return { kind: "stale" };
124
+ if (refresh.kind !== "completed") return { kind: refresh.kind === "cancelled" ? "back" : "closed" };
125
+ validateOwner();
126
+ const refreshed = refresh.value;
127
+ if (!refreshed) return { kind: "stale" };
104
128
  if (refreshed.kind === "empty") {
105
129
  options.onSelectionResolved?.();
106
130
  ctx.ui.notify("Remote storage no longer has a snapshot or synced-content list.", "warning");
@@ -119,11 +143,11 @@ export async function showRemoteSelectionReview(
119
143
  currentDecision = decisionFromState(refreshed.config, refreshed.state);
120
144
  }
121
145
  } catch (error) {
122
- if (signal?.aborted) return { kind: "stale" };
146
+ if (!isCurrent()) return { kind: "stale" };
123
147
  ctx.ui.notify(`Could not review synced content: ${errorMessage(error)}`, "error");
124
148
  return { kind: "back" };
125
149
  } finally {
126
- setSyncStatus(ctx, undefined);
150
+ if (isCurrent()) setSyncStatus(ctx, undefined);
127
151
  }
128
152
  }
129
153
 
@@ -137,6 +161,7 @@ async function showSelectionDifference(
137
161
  sessionSignal: AbortSignal | undefined,
138
162
  factory: SyncBackendFactory,
139
163
  options: Pick<RemoteSelectionReviewOptions, "cancelLabel" | "onSelectionResolved" | "withStateAccess">,
164
+ validateOwner: () => void,
140
165
  ): Promise<DifferenceFlowResult> {
141
166
  type Screen = "choice" | "review" | "saved";
142
167
  type Action = "adopt" | "keep" | "cancel" | "continue" | "done";
@@ -144,8 +169,9 @@ async function showSelectionDifference(
144
169
  decision: RemoteSelectionDecision;
145
170
  saved: boolean;
146
171
  }
172
+ const isCurrent = () => isReviewOwnerCurrent(validateOwner);
147
173
  let flowState: FlowState = { decision: initialDecision, saved: false };
148
- let continuationReview: PartialConfig | undefined;
174
+ let continuationFingerprint: string | undefined;
149
175
  let nextResult: RemoteSelectionReviewResult | undefined;
150
176
  let refreshRequested = false;
151
177
  const route = origin === "settings" ? "sync" : origin;
@@ -215,41 +241,58 @@ async function showSelectionDifference(
215
241
  adopt: async ({ state, signal: actionSignal }) => {
216
242
  const signal = combineSignals(sessionSignal, actionSignal);
217
243
  try {
218
- const currentConfig = await loadConfig(state.decision.setupName);
219
- if (signal.aborted) return { kind: "close" as const };
220
- assertLocalSelectionCurrent(currentConfig, state.decision);
221
- if (!currentConfig.include.includes("sessions") && state.decision.remoteInclude.includes("sessions")) {
222
- const acknowledged = await ctx.ui.confirm(
223
- "Use a content list that includes session conversations?",
224
- "Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the list only; it does not pull files.",
225
- { signal },
226
- );
227
- if (signal.aborted) return { kind: "close" as const };
228
- if (!acknowledged) return { kind: "stay" as const };
229
- }
230
- const review = await runWithOptionalStateAccess(options, async () => {
231
- const loaded = await loadAdoptionReview(state.decision, signal, factory);
232
- if (signal.aborted) throw signal.reason;
233
- await revalidateAndAdopt(loaded, state.decision, signal);
234
- return loaded;
235
- });
236
- if (signal.aborted) return { kind: "close" as const };
244
+ const adoption = await runCancellableTask(
245
+ ctx,
246
+ "Checking and saving remote content list…",
247
+ async (workSignal, _onCommit, onDialog) => {
248
+ const ownedSignal = workSignal ?? signal;
249
+ const currentConfig = await loadConfig(state.decision.setupName);
250
+ ownedSignal.throwIfAborted();
251
+ validateOwner();
252
+ assertLocalSelectionCurrent(currentConfig, state.decision);
253
+ if (!currentConfig.include.includes("sessions") && state.decision.remoteInclude.includes("sessions")) {
254
+ onDialog?.(true);
255
+ let acknowledged: boolean;
256
+ try {
257
+ acknowledged = await ctx.ui.confirm(
258
+ "Use a content list that includes session conversations?",
259
+ "Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the list only; it does not pull files.",
260
+ { signal: ownedSignal },
261
+ );
262
+ } finally {
263
+ onDialog?.(false);
264
+ }
265
+ ownedSignal.throwIfAborted();
266
+ validateOwner();
267
+ if (!acknowledged) return undefined;
268
+ }
269
+ return runWithOptionalStateAccess(options, async () => {
270
+ const loaded = await loadAdoptionReview(state.decision, ownedSignal, factory, validateOwner);
271
+ ownedSignal.throwIfAborted();
272
+ validateOwner();
273
+ await revalidateAndAdopt(loaded, state.decision, ownedSignal, validateOwner);
274
+ return loaded;
275
+ });
276
+ },
277
+ {
278
+ signal,
279
+ isCurrent,
280
+ cancelAcrossDialogs: true,
281
+ cancelledMessage: "Remote content-list adoption cancelled.",
282
+ },
283
+ );
284
+ if (signal.aborted || adoption.kind === "closed") return { kind: "close" as const };
285
+ if (adoption.kind === "cancelled" || !adoption.value) return { kind: "stay" as const };
286
+ validateOwner();
237
287
  options.onSelectionResolved?.();
238
- continuationReview = {
239
- ...review.storageReview,
240
- setupName: state.decision.setupName,
288
+ continuationFingerprint = syncCheckConfigFingerprint({
289
+ ...adoption.value.config,
241
290
  include: [...state.decision.remoteInclude],
242
- automatic: review.config.automatic,
243
- automaticTransfer: review.config.automaticTransfer,
244
- agentMode: review.config.agentMode,
245
- continuous: review.config.continuous,
246
- onSwitch: review.config.onSwitch,
247
- showStatus: review.config.showStatus,
248
- };
291
+ });
249
292
  flowState = { decision: state.decision, saved: true };
250
293
  return { kind: "to" as const, screen: "saved" as const };
251
294
  } catch (error) {
252
- if (signal.aborted) return { kind: "close" as const };
295
+ if (signal.aborted || !isCurrent()) return { kind: "close" as const };
253
296
  if (isStaleReviewError(error)) {
254
297
  ctx.ui.notify(`${errorMessage(error)} Refreshing the comparison.`, "warning");
255
298
  refreshRequested = true;
@@ -268,6 +311,7 @@ async function showSelectionDifference(
268
311
  try {
269
312
  const latest = await loadConfig(state.decision.setupName);
270
313
  if (signal.aborted) return { kind: "close" as const };
314
+ validateOwner();
271
315
  assertLocalSelectionCurrent(latest, state.decision);
272
316
  const result = await runCancellableOperation(
273
317
  ctx,
@@ -283,7 +327,7 @@ async function showSelectionDifference(
283
327
  );
284
328
  return handleNestedRouteResult(result, "push");
285
329
  } catch (error) {
286
- if (signal.aborted) return { kind: "close" as const };
330
+ if (signal.aborted || !isCurrent()) return { kind: "close" as const };
287
331
  if (isStaleReviewError(error)) {
288
332
  ctx.ui.notify(`${errorMessage(error)} Refreshing the comparison.`, "warning");
289
333
  refreshRequested = true;
@@ -294,12 +338,13 @@ async function showSelectionDifference(
294
338
  }
295
339
  },
296
340
  continue: async ({ state, signal: actionSignal }) => {
297
- if (!runRoute || !continuationReview) return { kind: "stay" as const };
341
+ if (!runRoute || !continuationFingerprint) return { kind: "stay" as const };
298
342
  const signal = combineSignals(sessionSignal, actionSignal);
299
343
  try {
300
- const latest = await loadPartialConfig(state.decision.setupName);
344
+ const latest = await loadConfig(state.decision.setupName);
301
345
  if (signal.aborted) return { kind: "close" as const };
302
- if (!sameContinuationReview(latest, continuationReview)) {
346
+ validateOwner();
347
+ if (syncCheckConfigFingerprint(latest) !== continuationFingerprint) {
303
348
  throw new StaleRemoteSelectionReviewError(
304
349
  `Sync setup “${safeTerminalText(state.decision.setupName)}” changed after the remote content list was saved.`,
305
350
  );
@@ -312,7 +357,7 @@ async function showSelectionDifference(
312
357
  });
313
358
  return handleNestedRouteResult(result, route);
314
359
  } catch (error) {
315
- if (signal.aborted) return { kind: "close" as const };
360
+ if (signal.aborted || !isCurrent()) return { kind: "close" as const };
316
361
  ctx.ui.notify(`Could not continue: ${errorMessage(error)}`, "error");
317
362
  return { kind: "stay" as const };
318
363
  }
@@ -350,10 +395,10 @@ async function showSelectionDifference(
350
395
  const menuResult = await runMenu(ctx, menu, {
351
396
  getState: () => flowState,
352
397
  signal: sessionSignal,
353
- isCurrent: () => !sessionSignal?.aborted,
398
+ isCurrent,
354
399
  onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
355
400
  });
356
- if (sessionSignal?.aborted || menuResult.kind === "stale") return { kind: "stale" };
401
+ if (!isCurrent() || menuResult.kind === "stale") return { kind: "stale" };
357
402
  if (refreshRequested) return { kind: "refresh" };
358
403
  if (nextResult) return nextResult;
359
404
  if (menuResult.kind === "closed") {
@@ -364,7 +409,6 @@ async function showSelectionDifference(
364
409
 
365
410
  interface AdoptionReview {
366
411
  config: AnySyncConfig;
367
- storageReview: SyncSetupStorageReview;
368
412
  backend: SyncBackend;
369
413
  reviewedHead: RemoteHead;
370
414
  }
@@ -373,53 +417,58 @@ async function loadAdoptionReview(
373
417
  decision: RemoteSelectionDecision,
374
418
  signal: AbortSignal,
375
419
  factory: SyncBackendFactory,
420
+ validateOwner: () => void,
376
421
  ): Promise<AdoptionReview> {
377
422
  const config = await loadConfig(decision.setupName);
378
423
  throwIfAborted(signal);
424
+ validateOwner();
379
425
  assertLocalSelectionCurrent(config, decision);
380
- const partial = await loadPartialConfig(decision.setupName);
381
- throwIfAborted(signal);
382
- if (!sameSyncInclude(partial.include, decision.localInclude)) {
383
- throw new StaleRemoteSelectionReviewError(
384
- `Sync setup “${safeTerminalText(decision.setupName)}” changed while the comparison was open.`,
385
- );
386
- }
387
426
  const backend = await factory(config);
388
427
  throwIfAborted(signal);
428
+ validateOwner();
389
429
  const reviewedHead = await backend.readHead(signal);
390
430
  throwIfAborted(signal);
431
+ validateOwner();
391
432
  if (!reviewedHead) {
392
433
  throw new StaleRemoteSelectionReviewError("Remote storage changed while the comparison was open.");
393
434
  }
394
435
  const snapshot = await readSnapshotForHead(backend, reviewedHead, signal);
395
436
  throwIfAborted(signal);
437
+ validateOwner();
396
438
  const state = inspectRemoteSelection(config.include, snapshot);
397
439
  if (state.kind !== "different" || !sameSyncInclude(state.include, decision.remoteInclude)) {
398
440
  throw new StaleRemoteSelectionReviewError("Remote synced content changed while the comparison was open.");
399
441
  }
400
- return { config, storageReview: partial, backend, reviewedHead };
442
+ return { config, backend, reviewedHead };
401
443
  }
402
444
 
403
- async function revalidateAndAdopt(review: AdoptionReview, decision: RemoteSelectionDecision, signal: AbortSignal) {
445
+ async function revalidateAndAdopt(
446
+ review: AdoptionReview,
447
+ decision: RemoteSelectionDecision,
448
+ signal: AbortSignal,
449
+ validateOwner: () => void,
450
+ ) {
404
451
  const currentHead = await review.backend.readHead(signal);
405
452
  throwIfAborted(signal);
453
+ validateOwner();
406
454
  if (!currentHead || !review.backend.sameRevision(review.reviewedHead.revision, currentHead.revision)) {
407
455
  throw new StaleRemoteSelectionReviewError("Remote storage changed while the comparison was open.");
408
456
  }
409
457
  const currentSnapshot = await readSnapshotForHead(review.backend, currentHead, signal);
410
458
  throwIfAborted(signal);
459
+ validateOwner();
411
460
  const currentState = inspectRemoteSelection(review.config.include, currentSnapshot);
412
461
  if (currentState.kind !== "different" || !sameSyncInclude(currentState.include, decision.remoteInclude)) {
413
462
  throw new StaleRemoteSelectionReviewError("Remote synced content changed while the comparison was open.");
414
463
  }
415
464
  await updateSyncSetup(
416
465
  decision.setupName,
417
- (setup) => ({
418
- ...setup,
419
- sync: { ...setup.sync, include: [...decision.remoteInclude] },
420
- }),
466
+ (setup) => {
467
+ validateOwner();
468
+ return { ...setup, sync: { ...setup.sync, include: [...decision.remoteInclude] } };
469
+ },
421
470
  {
422
- expectedStorage: review.storageReview,
471
+ expectedConfigFingerprint: syncCheckConfigFingerprint(review.config),
423
472
  expectedInclude: decision.localInclude,
424
473
  signal,
425
474
  },
@@ -431,6 +480,7 @@ async function inspectConfiguredRemoteSelection(
431
480
  setupName: string | undefined,
432
481
  signal: AbortSignal | undefined,
433
482
  factory: SyncBackendFactory,
483
+ validateOwner: () => void,
434
484
  ): Promise<
435
485
  | { kind: "empty"; config: AnySyncConfig }
436
486
  | { kind: "selection"; config: AnySyncConfig; state: RemoteSelectionState }
@@ -438,14 +488,18 @@ async function inspectConfiguredRemoteSelection(
438
488
  > {
439
489
  const config = await loadConfig(setupName);
440
490
  if (signal?.aborted) return undefined;
491
+ validateOwner();
441
492
  setSyncStatus(ctx, `checking synced content for ${safeTerminalText(config.setupName)}`);
442
493
  const backend = await factory(config);
443
494
  if (signal?.aborted) return undefined;
495
+ validateOwner();
444
496
  const head = await backend.readHead(signal);
445
497
  if (signal?.aborted) return undefined;
498
+ validateOwner();
446
499
  if (!head) return { kind: "empty", config };
447
500
  const snapshot = await readSnapshotForHead(backend, head, signal);
448
501
  if (signal?.aborted) return undefined;
502
+ validateOwner();
449
503
  return {
450
504
  kind: "selection",
451
505
  config,
@@ -587,20 +641,6 @@ function assertLocalSelectionCurrent(config: AnySyncConfig, decision: RemoteSele
587
641
  );
588
642
  }
589
643
 
590
- function sameContinuationReview(left: PartialConfig, right: PartialConfig) {
591
- return (
592
- left.setupName === right.setupName &&
593
- left.connectionName === right.connectionName &&
594
- left.storageKind === right.storageKind &&
595
- left.storagePath === right.storagePath &&
596
- left.bucket === right.bucket &&
597
- left.branch === right.branch &&
598
- Boolean(left.agentMode) === Boolean(right.agentMode) &&
599
- Boolean(left.continuous) === Boolean(right.continuous) &&
600
- sameSyncInclude(left.include, right.include)
601
- );
602
- }
603
-
604
644
  function continueLabel(origin: RemoteSelectionOrigin) {
605
645
  if (origin === "pull") return "Continue Pull now…";
606
646
  if (origin === "push") return "Continue Push now…";
@@ -641,6 +681,15 @@ function runWithOptionalStateAccess<T>(
641
681
  return options.withStateAccess ? options.withStateAccess(task) : task();
642
682
  }
643
683
 
684
+ function isReviewOwnerCurrent(validateOwner: () => void) {
685
+ try {
686
+ validateOwner();
687
+ return true;
688
+ } catch {
689
+ return false;
690
+ }
691
+ }
692
+
644
693
  function combineSignals(sessionSignal: AbortSignal | undefined, actionSignal: AbortSignal) {
645
694
  return sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
646
695
  }
@@ -33,6 +33,7 @@ export async function showSyncSettings(
33
33
  ctx: ExtensionCommandContext,
34
34
  runRoute: SyncSettingsRoute,
35
35
  signal?: AbortSignal,
36
+ reviewedSetup?: string,
36
37
  ): Promise<"back" | "exit"> {
37
38
  let disposition: "back" | "exit" = "back";
38
39
  const exitSettings = () => {
@@ -71,7 +72,7 @@ export async function showSyncSettings(
71
72
  }
72
73
  return disposition;
73
74
  }
74
- const initial = await loadConfig();
75
+ const initial = await loadConfig(reviewedSetup);
75
76
  if (signal?.aborted) return "back";
76
77
  const setupName = initial.setupName;
77
78
  type Action =