@narumitw/pi-sync 0.54.2 → 0.56.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 (194) hide show
  1. package/README.md +40 -15
  2. package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
  3. package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
  4. package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
  5. package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
  6. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
  7. package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
  8. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
  9. package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
  10. package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
  11. package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
  12. package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
  13. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-AAEEHR7H.ts} +31 -87
  14. package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
  15. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-B4QZBDBT.ts} +2 -2
  16. package/dist/chunks/{chunk-CL7BZRRQ.ts → chunk-CRKTAPSL.ts} +8 -8
  17. package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
  18. package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
  19. package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
  20. package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
  21. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-EUMINX7L.ts} +6 -6
  22. package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
  23. package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
  24. package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
  25. package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
  26. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-GP5DU62N.ts} +8 -9
  27. package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
  28. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-GSNPASKB.ts} +2 -2
  29. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-IWZH5KYS.ts} +23 -7
  30. package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
  31. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-JJTARUSO.ts} +15 -14
  32. package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
  33. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-NLWS55B6.ts} +15 -2
  34. package/dist/chunks/chunk-NLWS55B6.ts.map +7 -0
  35. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-OEWQVWLC.ts} +4 -4
  36. package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-OTZRCKYP.ts} +380 -510
  37. package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
  38. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-OWBAZA6J.ts} +24 -12
  39. package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
  40. package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
  41. package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
  42. package/dist/chunks/chunk-QGK445K7.ts +21 -0
  43. package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
  44. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-QO333NM4.ts} +49 -21
  45. package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
  46. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-QSHMOL3I.ts} +22 -24
  47. package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
  48. package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
  49. package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
  50. package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
  51. package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
  52. package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
  53. package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
  54. package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
  55. package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
  56. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-UPNVIUBN.ts} +3 -3
  57. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  58. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  59. package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
  60. package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
  61. package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
  62. package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
  63. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-YK52VDXG.ts} +6 -5
  64. package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
  65. package/dist/chunks/config-KGIYTTXQ.ts +37 -0
  66. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
  67. package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-6WIEQLXW.ts} +31 -24
  68. package/dist/chunks/{conflict-review-4PLVLMRO.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
  69. package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
  70. package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
  71. package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
  72. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-3JJYOVNR.ts} +5 -5
  73. package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
  74. package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
  75. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
  76. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-KRAW3NE2.ts} +464 -164
  77. package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
  78. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-RFCAH7KL.ts} +7 -5
  79. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-5725JDNC.ts} +9 -8
  80. package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
  81. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y7WSCLJI.ts} +38 -38
  82. package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
  83. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-E7PHKI6J.ts} +7 -6
  84. package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
  85. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-65DROCIJ.ts} +14 -13
  86. package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
  87. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-XFJOAMJD.ts} +9 -8
  88. package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
  89. package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-HDUDT5PD.ts} +188 -73
  90. package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
  91. package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
  92. package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
  93. package/dist/index.ts +246 -131
  94. package/dist/index.ts.map +3 -3
  95. package/docs/agent-mode-verification.md +99 -0
  96. package/docs/continuous-sync-verification.md +87 -0
  97. package/docs/settings.md +131 -11
  98. package/docs/tui-contract.md +61 -0
  99. package/package.json +3 -2
  100. package/src/backends/git/git-backend.ts +1 -4
  101. package/src/backends/git/git-runner.ts +1 -5
  102. package/src/backends/s3/s3-backend.ts +3 -25
  103. package/src/backends/s3/s3-client.ts +14 -2
  104. package/src/backends/snapshot-pointer.ts +27 -0
  105. package/src/backends/webdav/webdav-backend.ts +3 -29
  106. package/src/backends/webdav/webdav-client.ts +11 -7
  107. package/src/commands/command-execution.ts +7 -3
  108. package/src/commands/command-handler.ts +15 -3
  109. package/src/commands/command-types.ts +10 -0
  110. package/src/commands/command.ts +4 -1
  111. package/src/settings/config.ts +20 -10
  112. package/src/settings/settings-store.ts +1 -34
  113. package/src/settings/settings-types.ts +10 -0
  114. package/src/settings/settings-validation.ts +4 -1
  115. package/src/snapshot/snapshot-codec.ts +1 -5
  116. package/src/state/lock.ts +13 -10
  117. package/src/state/merge-baseline-store.ts +19 -1
  118. package/src/state/state-directory.ts +8 -1
  119. package/src/sync/agent-candidate.ts +251 -0
  120. package/src/sync/agent-plan.ts +87 -0
  121. package/src/sync/agent-policy.ts +13 -0
  122. package/src/sync/agent-request.ts +17 -0
  123. package/src/sync/agent-runner.ts +188 -0
  124. package/src/sync/agent-store.ts +151 -0
  125. package/src/sync/agent-sync.ts +91 -0
  126. package/src/sync/apply-review.ts +63 -0
  127. package/src/sync/automatic-sync.ts +46 -33
  128. package/src/sync/continuous-errors.ts +85 -0
  129. package/src/sync/continuous-lease.ts +221 -0
  130. package/src/sync/continuous-record.ts +179 -0
  131. package/src/sync/continuous-storage.ts +55 -0
  132. package/src/sync/continuous-sync.ts +516 -0
  133. package/src/sync/continuous-watch.ts +59 -0
  134. package/src/sync/merged-sync.ts +268 -112
  135. package/src/sync/remote-snapshot.ts +2 -3
  136. package/src/sync/settings-merge.ts +19 -3
  137. package/src/sync/setup-switch.ts +1 -5
  138. package/src/sync/startup-check.ts +51 -29
  139. package/src/sync/startup-timings.ts +90 -0
  140. package/src/sync/sync-inspection.ts +16 -5
  141. package/src/sync/sync-mutations.ts +55 -28
  142. package/src/sync/sync-queries.ts +19 -3
  143. package/src/sync/text-merge.ts +3 -3
  144. package/src/sync-extension.ts +75 -19
  145. package/src/ui/agent-mode-setting.ts +91 -0
  146. package/src/ui/manager-state.ts +12 -6
  147. package/src/ui/manager-ui.ts +57 -7
  148. package/src/ui/merge-review.ts +3 -3
  149. package/src/ui/merged-sync-summary.ts +2 -18
  150. package/src/ui/remote-selection-ui.ts +5 -5
  151. package/src/ui/settings-frame.ts +55 -0
  152. package/src/ui/settings-ui.ts +223 -43
  153. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  154. package/src/ui/setup/setup-name-ui.ts +2 -5
  155. package/src/ui/setup/setup-prompts.ts +4 -3
  156. package/src/ui/setup/setup-switcher.ts +2 -0
  157. package/src/ui/sync-behavior.ts +15 -0
  158. package/src/ui/sync-format.ts +69 -38
  159. package/src/ui/sync-setups-ui.ts +3 -5
  160. package/src/ui/transfer-changes.ts +33 -0
  161. package/src/ui/transfer-review.ts +27 -0
  162. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  163. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  164. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  165. package/dist/chunks/chunk-5ZWQ33XL.ts.map +0 -7
  166. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  167. package/dist/chunks/chunk-IJ6VV6FN.ts.map +0 -7
  168. package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
  169. package/dist/chunks/chunk-ORQRHEEO.ts +0 -362
  170. package/dist/chunks/chunk-ORQRHEEO.ts.map +0 -7
  171. package/dist/chunks/chunk-QE7NZZWT.ts.map +0 -7
  172. package/dist/chunks/chunk-UQ3ALBIB.ts.map +0 -7
  173. package/dist/chunks/chunk-V6VZ5TLG.ts.map +0 -7
  174. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  175. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  176. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  177. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  178. package/dist/chunks/s3-backend-AQGV6LFT.ts.map +0 -7
  179. package/dist/chunks/setup-wizard-GSDPDFC4.ts.map +0 -7
  180. package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
  181. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  182. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  183. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  184. /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  185. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  186. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  187. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  188. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  189. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  190. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  191. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  192. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  193. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  194. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -22,7 +22,12 @@ import {
22
22
  scanSnapshot,
23
23
  } from "../snapshot/snapshot.js";
24
24
  import type { Snapshot } from "../snapshot/snapshot-types.js";
25
- import { pruneMergeBaselines, readMergeAncestors, stageMergeBaseline } from "../state/merge-baseline-store.js";
25
+ import {
26
+ hasCurrentMergeBaseline,
27
+ pruneMergeBaselines,
28
+ readMergeAncestors,
29
+ stageMergeBaseline,
30
+ } from "../state/merge-baseline-store.js";
26
31
  import {
27
32
  readStateForConfig,
28
33
  statePathForConfig,
@@ -32,6 +37,7 @@ import {
32
37
  import { confirmMergeReview } from "../ui/merge-review.js";
33
38
  import { mergedSyncSummary } from "../ui/merged-sync-summary.js";
34
39
  import { safeTerminalText } from "../ui/terminal-text.js";
40
+ import { startupTransferEnabled } from "./agent-policy.js";
35
41
  import { releaseConflictArtifacts } from "./conflict-artifact-ownership.js";
36
42
  import {
37
43
  type CreatedConflictArtifact,
@@ -60,6 +66,7 @@ import {
60
66
  requireCompatibleRemoteSelection,
61
67
  } from "./remote-snapshot.js";
62
68
  import { resolveSettingsConflicts } from "./settings-conflicts.js";
69
+ import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
63
70
  import { createSyncDecision } from "./sync-decision.js";
64
71
  import { backupLocal, captureMutationOwner, protectedSessionPaths } from "./sync-local.js";
65
72
  import { inspectRemoteSelection } from "./sync-policy.js";
@@ -75,7 +82,12 @@ export async function mergeSync(
75
82
  factory: SyncBackendFactory = createSyncBackend,
76
83
  resolution?: ConflictResolution,
77
84
  ) {
78
- const validateOwner = captureMutationOwner(ctx, options.signal);
85
+ const timings = options.startupTimings ?? createStartupTimings();
86
+ const sessionOwner = captureMutationOwner(ctx, options.signal);
87
+ const validateOwner = (config?: AnySyncConfig) => {
88
+ sessionOwner();
89
+ options.background?.validate(config);
90
+ };
79
91
  const isCurrent = () => {
80
92
  try {
81
93
  validateOwner();
@@ -85,7 +97,9 @@ export async function mergeSync(
85
97
  }
86
98
  };
87
99
  const config = await loadConfig(options.setup);
100
+ validateOwner(config);
88
101
  const configToken = syncCheckConfigFingerprint(config);
102
+ const modelToken = JSON.stringify(ctx.model);
89
103
  const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : undefined;
90
104
  validateOwner();
91
105
  const snapshotOptions = {
@@ -93,33 +107,43 @@ export async function mergeSync(
93
107
  sessionDir: sessionRoot,
94
108
  signal: options.signal,
95
109
  };
96
- const validate = async () => {
97
- validateOwner();
98
- const latest = await loadConfig(options.setup);
99
- validateOwner();
100
- if (configToken !== syncCheckConfigFingerprint(latest))
101
- throw new Error("Sync setup or settings changed during transfer; reopen sync.");
102
- if (sessionRoot !== undefined) {
103
- const currentRoot = await effectiveSessionRoot(ctx);
110
+ const validate = () =>
111
+ timings.measure("transfer authorization", async () => {
104
112
  validateOwner();
105
- if (currentRoot !== sessionRoot)
106
- throw new Error("Session storage root changed during transfer; evidence retained for review.");
107
- }
108
- if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
109
- throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
110
- };
113
+ const latest = await loadConfig(options.setup);
114
+ validateOwner(latest);
115
+ if (configToken !== syncCheckConfigFingerprint(latest))
116
+ throw new Error("Sync setup or settings changed during transfer; reopen sync.");
117
+ if (sessionRoot !== undefined) {
118
+ const currentRoot = await effectiveSessionRoot(ctx);
119
+ validateOwner();
120
+ if (currentRoot !== sessionRoot)
121
+ throw new Error("Session storage root changed during transfer; evidence retained for review.");
122
+ }
123
+ if (config.agentMode && JSON.stringify(ctx.model) !== modelToken)
124
+ throw new Error("Agent mode paused: selected model changed during transfer.");
125
+ if (options.auto && (!startupTransferEnabled(latest) || !ctx.isIdle()))
126
+ throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
127
+ });
111
128
  await validate();
112
- const backend = await factory(config);
129
+ const backend = await timings.measure("transfer backend initialization", () => factory(config));
113
130
  await validate();
114
- if (options.auto && backend.capability === "read-check-write-verify") {
115
- throw new Error(
116
- "Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Use manual sync.",
117
- );
118
- }
119
- const pending = await readMergeJournal(config);
131
+ const pending = await timings.measure("transfer journal read", () => readMergeJournal(config));
120
132
  await validate();
121
133
  if (pending) {
122
- const recovered = await completeJournal(ctx, config, backend, pending, validate, options.signal, options.auto);
134
+ const recovered = await timings.measure("transfer journal recovery", () =>
135
+ completeJournal(
136
+ ctx,
137
+ config,
138
+ backend,
139
+ pending,
140
+ validate,
141
+ timings,
142
+ options.signal,
143
+ options.auto,
144
+ options.background?.validate,
145
+ ),
146
+ );
123
147
  if (!recovered) {
124
148
  if ((options.auto || !options.silent) && !options.signal?.aborted)
125
149
  ctx.ui.notify(
@@ -132,24 +156,69 @@ export async function mergeSync(
132
156
  ctx.ui.notify("Recovered the committed merged transfer. Run sync again to inspect newer changes.", "info");
133
157
  return "applied" as const;
134
158
  }
159
+ // Recovery above never publishes. This gate applies only to new autonomous transfers.
160
+ if ((options.auto || config.agentMode) && backend.capability === "read-check-write-verify") {
161
+ throw new Error(
162
+ "Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Disable Agent mode and use manual sync.",
163
+ );
164
+ }
135
165
  const textBudget = { remaining: TEXT_MERGE_WORK_LIMIT };
136
166
  const createdArtifacts: CreatedConflictArtifact[] = [];
137
167
  const ownedStatePath = statePathForConfig(config);
138
168
  try {
139
169
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
140
170
  await validate();
141
- const state = await readStateForConfig(config);
171
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
142
172
  if (state.unresolved?.length && !config.partialSync)
143
173
  throw new Error(
144
174
  "Partial progress remains unresolved; re-enable partial sync or review an explicit force direction.",
145
175
  );
146
- const localRaw = await createSnapshot(config.snapshotIdentity, snapshotOptions);
176
+ const localRaw = await timings.measure("transfer local snapshot", () =>
177
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
178
+ );
147
179
  const local = portableSnapshot(localRaw, config.localFields);
148
180
  if (!sameLocalFields(state.localFields, config.localFields))
149
181
  throw new Error("Local-field rules changed; review and confirm a directional migration first.");
150
- const head = await backend.readHead(options.signal);
182
+ const head = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
151
183
  await validate();
152
- const rawRemote = head ? await readSnapshotForHead(backend, head, options.signal) : undefined;
184
+ // This is a read-only shortcut, never authority to apply or publish. Pending
185
+ // journals were handled above. Legacy/partial baselines retain the full path.
186
+ if (
187
+ options.auto &&
188
+ !resolution &&
189
+ !config.partialSync &&
190
+ head &&
191
+ state.lastAppliedSnapshot &&
192
+ state.lastRemoteRevision &&
193
+ !state.unresolved?.length &&
194
+ head.snapshotId === state.lastAppliedSnapshot &&
195
+ backend.sameRevision(head.revision, state.lastRemoteRevision) &&
196
+ inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind === "same" &&
197
+ matchesAcceptedState(state, config, head, local)
198
+ ) {
199
+ const unchanged = await timings.measure("transfer unchanged verification", async () => {
200
+ if (!(await hasCurrentMergeBaseline(config, local, state))) return false;
201
+ await validate();
202
+ const currentLocal = await timings.measure("transfer local snapshot", () =>
203
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
204
+ );
205
+ await validate();
206
+ const currentState = await timings.measure("transfer state read", () => readStateForConfig(config));
207
+ await validate();
208
+ return (
209
+ sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) &&
210
+ syncStateFingerprint(currentState) === syncStateFingerprint(state)
211
+ );
212
+ });
213
+ await validate();
214
+ if (unchanged) {
215
+ timings.markUnchanged();
216
+ return "applied" as const;
217
+ }
218
+ }
219
+ const rawRemote = head
220
+ ? await timings.measure("transfer snapshot download", () => readSnapshotForHead(backend, head, options.signal))
221
+ : undefined;
153
222
  await validate();
154
223
  if (rawRemote) {
155
224
  const files = [...local.files, ...rawRemote.files];
@@ -167,12 +236,15 @@ export async function mergeSync(
167
236
  : undefined;
168
237
  if (rawRemote) {
169
238
  requireCompatibleRemoteSelection(config, rawRemote);
170
- const validation = planFileMerge({
171
- baseline: state.lastFileHashes,
172
- local: local.files,
173
- remote: rawRemote.files,
174
- selectionCompatible: true,
175
- });
239
+ const validation = await timings.measure("transfer merge planning", () =>
240
+ planFileMerge({
241
+ baseline: state.lastFileHashes,
242
+ local: local.files,
243
+ remote: rawRemote.files,
244
+ selectionCompatible: true,
245
+ }),
246
+ );
247
+ await validate();
176
248
  const managedPaths = new Set([
177
249
  ...Object.keys(state.lastFileHashes),
178
250
  ...[...local.files, ...(remote?.files ?? [])].map((file) => file.path),
@@ -206,30 +278,32 @@ export async function mergeSync(
206
278
  throw review("The established remote is missing; choose an explicit recovery direction.", "remote-empty");
207
279
  const selectionState = inspectRemoteSelection(config.include, rawRemote);
208
280
  const selectionCompatible = selectionState.kind === "same";
209
- let plan = planFileMerge({
210
- baseline: state.lastFileHashes,
211
- local: local.files,
212
- remote: remote.files,
213
- selectionCompatible,
214
- protectedPaths: protectedSessionPaths(ctx, sessionRoot),
215
- });
281
+ let plan = await timings.measure("transfer merge planning", () =>
282
+ planFileMerge({
283
+ baseline: state.lastFileHashes,
284
+ local: local.files,
285
+ remote: remote.files,
286
+ selectionCompatible,
287
+ protectedPaths: protectedSessionPaths(ctx, sessionRoot),
288
+ }),
289
+ );
290
+ await validate();
216
291
  let resolved = { plan, fields: [] as string[] };
217
292
  if (config.mergeSettings) {
218
293
  try {
219
- resolved = await resolveSettingsConflicts(config, state, local, remote, plan);
294
+ resolved = await timings.measure("transfer settings merge", () =>
295
+ resolveSettingsConflicts(config, state, local, remote, plan),
296
+ );
220
297
  } catch (error) {
221
298
  if (!config.partialSync) throw error;
222
299
  }
223
300
  }
224
301
  await validate();
225
- plan = await resolveContentConflicts(
226
- config,
227
- state,
228
- local,
229
- remote,
230
- resolved.plan,
231
- protectedSessionPaths(ctx, sessionRoot),
232
- { textBudget, signal: options.signal },
302
+ plan = await timings.measure("transfer content merge", () =>
303
+ resolveContentConflicts(config, state, local, remote, resolved.plan, protectedSessionPaths(ctx, sessionRoot), {
304
+ textBudget,
305
+ signal: options.signal,
306
+ }),
233
307
  );
234
308
  await validate();
235
309
  if (resolution) {
@@ -246,7 +320,33 @@ export async function mergeSync(
246
320
  );
247
321
  await validate();
248
322
  }
249
- if (plan.kind !== "planned" || (plan.conflicts.length && (!config.partialSync || options.auto))) {
323
+ if (config.agentMode && !resolution) {
324
+ const { resolveAgentPlan } = await import("./agent-plan.js");
325
+ await validate();
326
+ plan = await resolveAgentPlan(
327
+ ctx,
328
+ options,
329
+ config,
330
+ backend,
331
+ state,
332
+ local,
333
+ localRaw,
334
+ remote,
335
+ head,
336
+ plan,
337
+ snapshotOptions,
338
+ validate,
339
+ );
340
+ await validate();
341
+ if (plan.kind !== "planned" || (plan.conflicts.length && !config.partialSync))
342
+ throw new Error(
343
+ "Agent mode paused: unsupported or refused conflict groups remain; no transfer was performed.",
344
+ );
345
+ }
346
+ if (
347
+ plan.kind !== "planned" ||
348
+ (plan.conflicts.length && (!config.partialSync || (options.auto && !config.agentMode)))
349
+ ) {
250
350
  const decision = review(
251
351
  selectionCompatible
252
352
  ? `Conflicting or protected paths require review; no merged transfer was performed. Use /sync diff and an explicit direction.${resolved.fields.length ? ` Settings fields: ${resolved.fields.map(safeTerminalText).join(", ")}` : ""}`
@@ -353,40 +453,49 @@ export async function mergeSync(
353
453
  Boolean(config.partialSync && rawRemote.version !== 3);
354
454
  const apply = !sameHashes(fileHashMap(localRaw), fileHashMap(after));
355
455
  if (!publish && !apply) {
356
- const beforeEvidence = await backend.readHead(options.signal);
456
+ const beforeEvidence = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
357
457
  await validate();
358
458
  if (!beforeEvidence || !backend.sameRevision(beforeEvidence.revision, head.revision))
359
459
  throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
360
460
  progress = await persistProgress();
361
461
  await validate();
362
- const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptions);
462
+ const currentLocal = await timings.measure("transfer local snapshot", () =>
463
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
464
+ );
363
465
  await validate();
364
466
  if (
365
467
  !sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) ||
366
- syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state)
468
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
469
+ syncStateFingerprint(state)
367
470
  )
368
471
  throw new Error(
369
472
  "Local content or baseline changed before baseline acceptance; retry from a fresh observation.",
370
473
  );
371
474
  await validate();
372
- const currentHead = await backend.readHead(options.signal);
475
+ const currentHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
373
476
  await validate();
374
477
  if (!currentHead || !backend.sameRevision(currentHead.revision, head.revision))
375
478
  throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
376
- await writeAcceptedState(config, head, accepted, validate, progress);
377
- await pruneMergeBaselines(
378
- config,
379
- acceptedState(config, head, accepted, progress),
380
- captureMutationOwner(ctx, options.signal),
479
+ await timings.measure("transfer baseline write", () =>
480
+ writeAcceptedState(config, head, accepted, validate, progress),
481
+ );
482
+ await timings.measure("transfer cleanup", () =>
483
+ pruneMergeBaselines(
484
+ config,
485
+ acceptedState(config, head, accepted, progress),
486
+ captureMutationOwner(ctx, options.signal),
487
+ ),
381
488
  );
382
489
  await validate();
383
- await pruneCompletedConflicts(
384
- config,
385
- backend.identity,
386
- acceptedState(config, head, accepted, progress),
387
- accepted,
388
- remote,
389
- captureMutationOwner(ctx, options.signal),
490
+ await timings.measure("transfer cleanup", () =>
491
+ pruneCompletedConflicts(
492
+ config,
493
+ backend.identity,
494
+ acceptedState(config, head, accepted, progress),
495
+ accepted,
496
+ remote,
497
+ captureMutationOwner(ctx, options.signal),
498
+ ),
390
499
  );
391
500
  await validate();
392
501
  if (!options.silent)
@@ -398,11 +507,13 @@ export async function mergeSync(
398
507
  );
399
508
  return "applied" as const;
400
509
  }
401
- await preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
510
+ await timings.measure("transfer preflight", () =>
511
+ preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.()),
512
+ );
402
513
  await validate();
403
514
  if (!config.skipSecretScan && scanSnapshot(upload).length)
404
515
  throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
405
- if (!options.yes) {
516
+ if (!options.yes && !config.agentMode) {
406
517
  const summary = mergedSyncSummary({
407
518
  setupName: config.setupName,
408
519
  destination: backend.destination,
@@ -430,19 +541,26 @@ export async function mergeSync(
430
541
  return "cancelled" as const;
431
542
  }
432
543
  await validate();
433
- const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptions);
544
+ const refreshed = await timings.measure("transfer local snapshot", () =>
545
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
546
+ );
434
547
  if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
435
548
  throw new Error("Local content changed during review; no merged transfer was performed.");
436
- if (syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state))
549
+ if (
550
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
551
+ syncStateFingerprint(state)
552
+ )
437
553
  throw new Error("Sync baseline changed during review; retry from a fresh observation.");
438
554
  await validate();
439
- const refreshedHead = await backend.readHead(options.signal);
555
+ const refreshedHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
440
556
  await validate();
441
557
  if (!refreshedHead || !backend.sameRevision(refreshedHead.revision, head.revision))
442
558
  throw new Error("Remote changed during review; no merged transfer was performed.");
443
559
  progress = await persistProgress();
444
560
  await validate();
445
- const backup = await backupLocal(config.snapshotIdentity, snapshotOptions, options.signal);
561
+ const backup = await timings.measure("transfer backup", () =>
562
+ backupLocal(config.snapshotIdentity, snapshotOptions, options.signal),
563
+ );
446
564
  await validate();
447
565
  const journal: MergeJournal = {
448
566
  version: 1,
@@ -458,11 +576,15 @@ export async function mergeSync(
458
576
  ...(sessionRoot !== undefined ? { sessionRoot } : {}),
459
577
  ...(!publish ? { committedHead: head, applyOnly: true } : {}),
460
578
  };
461
- await writeMergeJournal(config, journal);
579
+ await timings.measure("transfer journal write", () => writeMergeJournal(config, journal));
462
580
  // No backend call has begun: a stale candidate can be retired without ambiguous publication.
463
- const atCommitState = syncStateFingerprint(await readStateForConfig(config));
581
+ const atCommitState = syncStateFingerprint(
582
+ await timings.measure("transfer state read", () => readStateForConfig(config)),
583
+ );
464
584
  await validate();
465
- const atCommit = await createSnapshot(config.snapshotIdentity, snapshotOptions);
585
+ const atCommit = await timings.measure("transfer local snapshot", () =>
586
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
587
+ );
466
588
  await validate();
467
589
  if (!sameHashes(fileHashMap(atCommit), fileHashMap(localRaw)) || atCommitState !== journal.stateIdentity) {
468
590
  await validate();
@@ -473,7 +595,9 @@ export async function mergeSync(
473
595
  }
474
596
  // Backup/journal awaits can also change physical identity without changing any bytes.
475
597
  try {
476
- await preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
598
+ await timings.measure("transfer preflight", () =>
599
+ preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.()),
600
+ );
477
601
  await validate();
478
602
  } catch (error) {
479
603
  await validate();
@@ -482,13 +606,15 @@ export async function mergeSync(
482
606
  }
483
607
  try {
484
608
  if (publish) {
485
- const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
486
- signal: options.signal,
487
- onCommit: options.onCommit,
488
- });
609
+ const result = await timings.measure("transfer remote publication", () =>
610
+ backend.publishSnapshot(upload, expectedRemoteHead(head), {
611
+ signal: options.signal,
612
+ onCommit: options.onCommit,
613
+ }),
614
+ );
489
615
  journal.committedHead = result.head;
490
616
  journal.warnings = result.warnings;
491
- await writeMergeJournal(config, journal);
617
+ await timings.measure("transfer journal write", () => writeMergeJournal(config, journal));
492
618
  } else options.onCommit?.();
493
619
  } catch (error) {
494
620
  if (
@@ -504,7 +630,19 @@ export async function mergeSync(
504
630
  // Every uncertain/after-commit outcome retains evidence and is never blindly retried.
505
631
  throw mergeTransferError(error, config.backend);
506
632
  }
507
- const completed = await completeJournal(ctx, config, backend, journal, validate, options.signal, options.auto);
633
+ const completed = await timings.measure("transfer journal recovery", () =>
634
+ completeJournal(
635
+ ctx,
636
+ config,
637
+ backend,
638
+ journal,
639
+ validate,
640
+ timings,
641
+ options.signal,
642
+ options.auto,
643
+ options.background?.validate,
644
+ ),
645
+ );
508
646
  if (!completed) {
509
647
  if (!options.signal?.aborted && (options.auto || !options.silent))
510
648
  ctx.ui.notify(
@@ -529,7 +667,7 @@ export async function mergeSync(
529
667
  return "applied" as const;
530
668
  }
531
669
  } finally {
532
- await releaseConflictArtifacts(config, ownedStatePath, createdArtifacts);
670
+ await timings.measure("transfer cleanup", () => releaseConflictArtifacts(config, ownedStatePath, createdArtifacts));
533
671
  }
534
672
  }
535
673
 
@@ -539,8 +677,10 @@ async function completeJournal(
539
677
  backend: SyncBackend,
540
678
  journal: MergeJournal,
541
679
  validate: () => Promise<void>,
680
+ timings: StartupTimings,
542
681
  signal?: AbortSignal,
543
682
  auto = false,
683
+ validateBackground?: () => void,
544
684
  ) {
545
685
  if (
546
686
  journal.identity !== mergeJournalIdentity(config, backend.identity) ||
@@ -558,7 +698,7 @@ async function completeJournal(
558
698
  );
559
699
  }
560
700
  const snapshotOptions = { ...snapshotOptionsForContext(ctx, config), sessionDir: sessionRoot, signal };
561
- const head = await backend.readHead(signal);
701
+ const head = await timings.measure("transfer remote head", () => backend.readHead(signal));
562
702
  if (
563
703
  !journal.committedHead &&
564
704
  head &&
@@ -566,10 +706,13 @@ async function completeJournal(
566
706
  backend.matchesUncommittedRecoveryHead?.(head, journal.expectedHead))
567
707
  ) {
568
708
  await validate();
569
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
709
+ const local = await timings.measure("transfer local snapshot", () =>
710
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
711
+ );
570
712
  if (
571
713
  !sameHashes(fileHashMap(local), fileHashMap(journal.before)) ||
572
- syncStateFingerprint(await readStateForConfig(config)) !== journal.stateIdentity
714
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
715
+ journal.stateIdentity
573
716
  ) {
574
717
  throw new Error(
575
718
  "Uncertain publication is not active, but local content or baseline changed; keep the journal for reviewed recovery.",
@@ -582,7 +725,7 @@ async function completeJournal(
582
725
  // Baseline publication is the durable completion boundary. A later remote revision
583
726
  // cannot undo that acceptance; retire only the journal for the recorded commit.
584
727
  if (journal.committedHead && (!head || !backend.sameRevision(head.revision, journal.committedHead.revision))) {
585
- const state = await readStateForConfig(config);
728
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
586
729
  await validate();
587
730
  if (
588
731
  matchesAcceptedState(state, config, journal.committedHead, journal.accepted ?? journal.after, journal.progress)
@@ -595,8 +738,10 @@ async function completeJournal(
595
738
  }
596
739
  }
597
740
  if (journal.applyOnly && (!head || !backend.sameRevision(head.revision, journal.expectedHead.revision))) {
598
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
599
- const state = await readStateForConfig(config);
741
+ const local = await timings.measure("transfer local snapshot", () =>
742
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
743
+ );
744
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
600
745
  await validate();
601
746
  if (
602
747
  sameHashes(fileHashMap(local), fileHashMap(journal.before)) &&
@@ -616,14 +761,16 @@ async function completeJournal(
616
761
  "Publication outcome cannot be reconciled with the current remote head. Journal and backup retained; do not retry publication or restore old bytes blindly.",
617
762
  );
618
763
  }
619
- const observed = await readSnapshotForHead(backend, head, signal);
764
+ const observed = await timings.measure("transfer snapshot download", () =>
765
+ readSnapshotForHead(backend, head, signal),
766
+ );
620
767
  requireCompatibleRemoteSelection(config, observed);
621
768
  if (
622
769
  !sameHashes(fileHashMap(observed), fileHashMap(journal.upload)) ||
623
770
  JSON.stringify(observed.selection) !== JSON.stringify(journal.upload.selection)
624
771
  )
625
772
  throw new Error("Remote snapshot does not match the recorded merge publication.");
626
- const state = await readStateForConfig(config);
773
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
627
774
  if (matchesAcceptedState(state, config, head, journal.accepted ?? journal.after, journal.progress)) {
628
775
  await validate();
629
776
  await clearMergeJournal(config);
@@ -644,38 +791,47 @@ async function completeJournal(
644
791
  const validateOwner = captureMutationOwner(ctx, signal);
645
792
  const validateMutation = () => {
646
793
  validateOwner();
794
+ validateBackground?.();
647
795
  if (auto && !ctx.isIdle()) throw new Error("Automatic file apply is no longer idle; evidence retained.");
648
796
  };
649
- await applyMergedSnapshot(
650
- journal.before,
651
- journal.after,
652
- protectedSessionPaths(ctx, sessionRoot),
653
- { ...snapshotOptions, validateMutation },
654
- validate,
655
- ctx.sessionManager.getSessionFile?.(),
797
+ await timings.measure("transfer local apply", () =>
798
+ applyMergedSnapshot(
799
+ journal.before,
800
+ journal.after,
801
+ protectedSessionPaths(ctx, sessionRoot),
802
+ { ...snapshotOptions, validateMutation },
803
+ validate,
804
+ ctx.sessionManager.getSessionFile?.(),
805
+ ),
656
806
  );
657
807
  await validate();
658
- const headAtAcceptance = await backend.readHead(signal);
808
+ const headAtAcceptance = await timings.measure("transfer remote head", () => backend.readHead(signal));
659
809
  await validate();
660
810
  if (!headAtAcceptance || !backend.sameRevision(headAtAcceptance.revision, head.revision))
661
811
  throw new Error("Remote changed during merged apply; journal and backup retained for review.");
662
- await writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress);
812
+ await timings.measure("transfer baseline write", () =>
813
+ writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress),
814
+ );
663
815
  await validate();
664
816
  await clearMergeJournal(config);
665
817
  await validate();
666
- await pruneMergeBaselines(
667
- config,
668
- acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
669
- captureMutationOwner(ctx, signal),
818
+ await timings.measure("transfer cleanup", () =>
819
+ pruneMergeBaselines(
820
+ config,
821
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
822
+ captureMutationOwner(ctx, signal),
823
+ ),
670
824
  );
671
825
  await validate();
672
- await pruneCompletedConflicts(
673
- config,
674
- backend.identity,
675
- acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
676
- journal.accepted ?? journal.after,
677
- journal.upload,
678
- captureMutationOwner(ctx, signal),
826
+ await timings.measure("transfer cleanup", () =>
827
+ pruneCompletedConflicts(
828
+ config,
829
+ backend.identity,
830
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
831
+ journal.accepted ?? journal.after,
832
+ journal.upload,
833
+ captureMutationOwner(ctx, signal),
834
+ ),
679
835
  );
680
836
  await validate();
681
837
  return true;
@@ -5,6 +5,7 @@ import { filterSnapshotForConfigPolicy } from "../snapshot/snapshot.js";
5
5
  import type { Snapshot } from "../snapshot/snapshot-types.js";
6
6
  import { safeTerminalText } from "../ui/terminal-text.js";
7
7
  import { sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
8
+ import { throwIfAborted } from "./signals.js";
8
9
  import {
9
10
  inspectRemoteSelection,
10
11
  type RemoteSelectionState,
@@ -15,9 +16,7 @@ import {
15
16
 
16
17
  export async function readSnapshotForHead(backend: SyncBackend, head: RemoteHead, signal?: AbortSignal) {
17
18
  const snapshot = await backend.readSnapshot(head.snapshotRef, signal);
18
- if (signal?.aborted) {
19
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
20
- }
19
+ throwIfAborted(signal);
21
20
  validatePortableSnapshot(snapshot);
22
21
  if (snapshot.id !== head.snapshotId) {
23
22
  throw new Error(`Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`);