@narumitw/pi-sync 0.54.3 → 0.57.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 +38 -11
  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-GC3QOL6J.ts → chunk-AAEEHR7H.ts} +31 -87
  14. package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
  15. package/dist/chunks/{chunk-R35N4CL3.ts → chunk-B4QZBDBT.ts} +2 -2
  16. package/dist/chunks/{chunk-KGE47GCZ.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-HMXUKU43.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-YTHQEW35.ts → chunk-GP5DU62N.ts} +8 -9
  27. package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
  28. package/dist/chunks/{chunk-AAZDBIBJ.ts → chunk-GSNPASKB.ts} +2 -2
  29. package/dist/chunks/{chunk-E6ONFG57.ts → chunk-IWZH5KYS.ts} +11 -8
  30. package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
  31. package/dist/chunks/{chunk-CAGJOKB7.ts → chunk-JJTARUSO.ts} +15 -14
  32. package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
  33. package/dist/chunks/{chunk-ITFR6NNQ.ts → chunk-NLWS55B6.ts} +2 -1
  34. package/dist/chunks/{chunk-ITFR6NNQ.ts.map → chunk-NLWS55B6.ts.map} +2 -2
  35. package/dist/chunks/{chunk-2FETCGGT.ts → chunk-OEWQVWLC.ts} +4 -4
  36. package/dist/chunks/{chunk-35GM6XKM.ts → chunk-OTZRCKYP.ts} +128 -382
  37. package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
  38. package/dist/chunks/{chunk-XU2VXGQV.ts → chunk-OWBAZA6J.ts} +14 -8
  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-KATKRR3D.ts → chunk-QO333NM4.ts} +32 -11
  45. package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
  46. package/dist/chunks/{chunk-I4JYVFUS.ts → chunk-QSHMOL3I.ts} +21 -22
  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-VCL7TZYW.ts → chunk-UPNVIUBN.ts} +3 -3
  57. package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
  58. package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
  59. package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
  60. package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
  61. package/dist/chunks/{chunk-A3TFXY4O.ts → chunk-YK52VDXG.ts} +6 -5
  62. package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
  63. package/dist/chunks/config-KGIYTTXQ.ts +37 -0
  64. package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
  65. package/dist/chunks/{conflict-review-ZOTOHL2E.ts → conflict-review-6WIEQLXW.ts} +31 -25
  66. package/dist/chunks/{conflict-review-ZOTOHL2E.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
  67. package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
  68. package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
  69. package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
  70. package/dist/chunks/{file-selection-UNHBVVON.ts → file-selection-3JJYOVNR.ts} +5 -5
  71. package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
  72. package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
  73. package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
  74. package/dist/chunks/{manager-ui-ZIN4BUSF.ts → manager-ui-KRAW3NE2.ts} +184 -45
  75. package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
  76. package/dist/chunks/{merge-baseline-store-ICXVTCML.ts → merge-baseline-store-RFCAH7KL.ts} +5 -5
  77. package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts → remote-selection-ui-5725JDNC.ts} +9 -8
  78. package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
  79. package/dist/chunks/{s3-backend-Y4P3O5B5.ts → s3-backend-Y7WSCLJI.ts} +38 -38
  80. package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
  81. package/dist/chunks/{setup-switch-ZMW5C4M7.ts → setup-switch-E7PHKI6J.ts} +7 -6
  82. package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
  83. package/dist/chunks/{setup-wizard-VW7F3JN4.ts → setup-wizard-65DROCIJ.ts} +14 -13
  84. package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
  85. package/dist/chunks/{sync-inspection-3EMS2J5B.ts → sync-inspection-XFJOAMJD.ts} +8 -8
  86. package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
  87. package/dist/chunks/{sync-operations-2CWST333.ts → sync-operations-HDUDT5PD.ts} +62 -63
  88. package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
  89. package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
  90. package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
  91. package/dist/index.ts +165 -85
  92. package/dist/index.ts.map +3 -3
  93. package/docs/agent-mode-verification.md +99 -0
  94. package/docs/continuous-sync-verification.md +87 -0
  95. package/docs/settings.md +32 -247
  96. package/package.json +11 -5
  97. package/skills/configure-pi-sync/SKILL.md +54 -0
  98. package/skills/configure-pi-sync/references/configuration.md +650 -0
  99. package/src/backends/git/git-backend.ts +1 -4
  100. package/src/backends/git/git-runner.ts +1 -5
  101. package/src/backends/s3/s3-backend.ts +3 -25
  102. package/src/backends/s3/s3-client.ts +14 -2
  103. package/src/backends/snapshot-pointer.ts +27 -0
  104. package/src/backends/webdav/webdav-backend.ts +3 -29
  105. package/src/backends/webdav/webdav-client.ts +11 -7
  106. package/src/commands/command-execution.ts +5 -2
  107. package/src/commands/command-handler.ts +15 -3
  108. package/src/commands/command-types.ts +6 -0
  109. package/src/commands/command.ts +1 -0
  110. package/src/settings/config.ts +7 -0
  111. package/src/settings/settings-store.ts +0 -34
  112. package/src/settings/settings-types.ts +8 -0
  113. package/src/settings/settings-validation.ts +1 -1
  114. package/src/snapshot/snapshot-codec.ts +1 -5
  115. package/src/state/lock.ts +13 -10
  116. package/src/state/merge-baseline-store.ts +2 -2
  117. package/src/state/state-directory.ts +8 -1
  118. package/src/sync/agent-candidate.ts +251 -0
  119. package/src/sync/agent-plan.ts +87 -0
  120. package/src/sync/agent-policy.ts +13 -0
  121. package/src/sync/agent-request.ts +17 -0
  122. package/src/sync/agent-runner.ts +188 -0
  123. package/src/sync/agent-store.ts +151 -0
  124. package/src/sync/agent-sync.ts +91 -0
  125. package/src/sync/automatic-sync.ts +2 -1
  126. package/src/sync/continuous-errors.ts +85 -0
  127. package/src/sync/continuous-lease.ts +221 -0
  128. package/src/sync/continuous-record.ts +179 -0
  129. package/src/sync/continuous-storage.ts +55 -0
  130. package/src/sync/continuous-sync.ts +516 -0
  131. package/src/sync/continuous-watch.ts +59 -0
  132. package/src/sync/merged-sync.ts +70 -12
  133. package/src/sync/remote-snapshot.ts +2 -3
  134. package/src/sync/settings-merge.ts +19 -3
  135. package/src/sync/setup-switch.ts +1 -5
  136. package/src/sync/startup-check.ts +15 -11
  137. package/src/sync/sync-inspection.ts +8 -1
  138. package/src/sync/sync-mutations.ts +6 -17
  139. package/src/sync/sync-queries.ts +5 -0
  140. package/src/sync/text-merge.ts +3 -3
  141. package/src/sync-extension.ts +75 -19
  142. package/src/ui/agent-mode-setting.ts +91 -0
  143. package/src/ui/manager-state.ts +2 -0
  144. package/src/ui/remote-selection-ui.ts +5 -5
  145. package/src/ui/settings-ui.ts +83 -1
  146. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  147. package/src/ui/setup/setup-name-ui.ts +2 -5
  148. package/src/ui/setup/setup-switcher.ts +2 -0
  149. package/src/ui/sync-setups-ui.ts +3 -5
  150. package/dist/chunks/chunk-26PMXY7E.ts +0 -362
  151. package/dist/chunks/chunk-26PMXY7E.ts.map +0 -7
  152. package/dist/chunks/chunk-35GM6XKM.ts.map +0 -7
  153. package/dist/chunks/chunk-A3TFXY4O.ts.map +0 -7
  154. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  155. package/dist/chunks/chunk-CAGJOKB7.ts.map +0 -7
  156. package/dist/chunks/chunk-E6ONFG57.ts.map +0 -7
  157. package/dist/chunks/chunk-GC3QOL6J.ts.map +0 -7
  158. package/dist/chunks/chunk-I4JYVFUS.ts.map +0 -7
  159. package/dist/chunks/chunk-KATKRR3D.ts.map +0 -7
  160. package/dist/chunks/chunk-XU2VXGQV.ts.map +0 -7
  161. package/dist/chunks/chunk-YTHQEW35.ts.map +0 -7
  162. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  163. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  164. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +0 -7
  165. package/dist/chunks/s3-backend-Y4P3O5B5.ts.map +0 -7
  166. package/dist/chunks/setup-wizard-VW7F3JN4.ts.map +0 -7
  167. package/dist/chunks/sync-operations-2CWST333.ts.map +0 -7
  168. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  169. /package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  170. /package/dist/chunks/{chunk-R35N4CL3.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  171. /package/dist/chunks/{chunk-KGE47GCZ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  172. /package/dist/chunks/{chunk-HMXUKU43.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-AAZDBIBJ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-2FETCGGT.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-VCL7TZYW.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  177. /package/dist/chunks/{merge-baseline-store-ICXVTCML.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  178. /package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  179. /package/dist/chunks/{file-selection-UNHBVVON.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  180. /package/dist/chunks/{setup-switch-ZMW5C4M7.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  181. /package/dist/chunks/{sync-inspection-3EMS2J5B.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { posixJoin } from "../../paths.js";
5
5
  import type { Snapshot, SnapshotFile } from "../../snapshot/snapshot-types.js";
6
6
  import { stateDir } from "../../state/state-directory.js";
7
+ import { throwIfAborted } from "../../sync/signals.js";
7
8
  import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
8
9
  import type { ResolvedGitBackend } from "../backend-types.js";
9
10
  import {
@@ -781,7 +782,3 @@ function sha256(value: Buffer) {
781
782
  function abortReason(signal: AbortSignal) {
782
783
  return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
783
784
  }
784
-
785
- function throwIfAborted(signal?: AbortSignal) {
786
- if (signal?.aborted) throw abortReason(signal);
787
- }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import process from "node:process";
3
+ import { throwIfAborted } from "../../sync/signals.js";
3
4
 
4
5
  const DEFAULT_TIMEOUT_MS = 30_000;
5
6
  const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
@@ -252,8 +253,3 @@ export async function readGitBlobs(
252
253
  });
253
254
  return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes);
254
255
  }
255
-
256
- function throwIfAborted(signal?: AbortSignal) {
257
- if (!signal?.aborted) return;
258
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
259
- }
@@ -4,9 +4,11 @@ import { sessionTokenWarnings } from "../../settings/config.js";
4
4
  import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
5
5
  import type { Snapshot } from "../../snapshot/snapshot-types.js";
6
6
  import { validatePortableSnapshot } from "../../sync/local-fields.js";
7
+ import { throwIfAborted } from "../../sync/signals.js";
7
8
  import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
8
9
  import { portableSnapshotSelection } from "../../sync/sync-policy.js";
9
10
  import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
11
+ import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
10
12
  import {
11
13
  type BackendDiagnostic,
12
14
  type ExpectedRemoteHead,
@@ -318,16 +320,7 @@ export function storageRoot(config: ResolvedS3Backend) {
318
320
  }
319
321
 
320
322
  export function pointerFor(config: ResolvedS3Backend, snapshot: Snapshot, checksum: string): LatestPointer {
321
- return {
322
- version: VERSION,
323
- profile: config.destination.namespace,
324
- snapshot: snapshot.id,
325
- sha256: checksum,
326
- createdAt: snapshot.createdAt,
327
- machine: snapshot.machine,
328
- syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
329
- ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
330
- };
323
+ return createLatestPointer(config.destination.namespace, snapshot, checksum);
331
324
  }
332
325
 
333
326
  export function s3BackendIdentity(config: ResolvedS3Backend) {
@@ -365,16 +358,6 @@ function trimSlashes(value: string) {
365
358
  return value.replace(/^\/+|\/+$/g, "");
366
359
  }
367
360
 
368
- function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
369
- return {
370
- snapshotRef: pointer.snapshot,
371
- snapshotId: pointer.snapshot,
372
- createdAt: pointer.createdAt,
373
- machine: pointer.machine,
374
- syncSessions: pointer.syncSessions === true,
375
- };
376
- }
377
-
378
361
  function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
379
362
  return {
380
363
  snapshotRef: pointer.snapshot,
@@ -529,11 +512,6 @@ function hasControlCharacter(value: string) {
529
512
  });
530
513
  }
531
514
 
532
- function throwIfAborted(signal?: AbortSignal) {
533
- if (!signal?.aborted) return;
534
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
535
- }
536
-
537
515
  function sha256(value: Buffer) {
538
516
  return createHash("sha256").update(value).digest("hex");
539
517
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { encodeKey, posixJoin } from "../../paths.js";
4
+ import { retryAfter } from "../../sync/continuous-errors.js";
4
5
  import type { RemoteObject, ResolvedS3Backend } from "../backend-types.js";
5
6
 
6
7
  const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
@@ -34,6 +35,7 @@ export class S3HttpError extends Error {
34
35
  constructor(
35
36
  message: string,
36
37
  readonly status: number,
38
+ readonly retryAfterMs?: number,
37
39
  ) {
38
40
  super(message);
39
41
  this.name = "S3HttpError";
@@ -81,7 +83,11 @@ export class S3Client {
81
83
  const object = await this.request("GET", key);
82
84
  if (object.status === 404) return { missing: true };
83
85
  if (!object.ok) {
84
- throw new S3HttpError(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`, object.status);
86
+ throw new S3HttpError(
87
+ `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
88
+ object.status,
89
+ retryAfter(object),
90
+ );
85
91
  }
86
92
  const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
87
93
  // R2 can intermittently return an empty 200 body (read-after-write
@@ -111,7 +117,11 @@ export class S3Client {
111
117
  const object = await this.request("GET", key);
112
118
  if (object.status === 404) return { missing: true };
113
119
  if (!object.ok) {
114
- throw new S3HttpError(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`, object.status);
120
+ throw new S3HttpError(
121
+ `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
122
+ object.status,
123
+ retryAfter(object),
124
+ );
115
125
  }
116
126
  const buffer = await readBoundedBuffer(object, MAX_SNAPSHOT_RESPONSE_BYTES, "S3 snapshot response");
117
127
  // R2 can intermittently return an empty 200 body on a long-lived
@@ -158,6 +168,7 @@ export class S3Client {
158
168
  throw new S3HttpError(
159
169
  `S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
160
170
  response.status,
171
+ retryAfter(response),
161
172
  );
162
173
  }
163
174
  await response.body?.cancel();
@@ -169,6 +180,7 @@ export class S3Client {
169
180
  throw new S3HttpError(
170
181
  `S3 DELETE failed (${response.status}): ${await this.readErrorText(response)}`,
171
182
  response.status,
183
+ retryAfter(response),
172
184
  );
173
185
  }
174
186
  await response.body?.cancel();
@@ -0,0 +1,27 @@
1
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
2
+ import type { LatestPointer } from "./backend-types.js";
3
+ import type { RemoteHistoryEntry } from "./sync-backend.js";
4
+
5
+ /** Shared latest/history metadata for the S3 and WebDAV object-store format. */
6
+ export function createLatestPointer(namespace: string, snapshot: Snapshot, checksum: string): LatestPointer {
7
+ return {
8
+ version: 1,
9
+ profile: namespace,
10
+ snapshot: snapshot.id,
11
+ sha256: checksum,
12
+ createdAt: snapshot.createdAt,
13
+ machine: snapshot.machine,
14
+ syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
15
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
16
+ };
17
+ }
18
+
19
+ export function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
20
+ return {
21
+ snapshotRef: pointer.snapshot,
22
+ snapshotId: pointer.snapshot,
23
+ createdAt: pointer.createdAt,
24
+ machine: pointer.machine,
25
+ syncSessions: pointer.syncSessions === true,
26
+ };
27
+ }
@@ -2,9 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
3
3
  import type { Snapshot } from "../../snapshot/snapshot-types.js";
4
4
  import { validatePortableSnapshot } from "../../sync/local-fields.js";
5
+ import { throwIfAborted } from "../../sync/signals.js";
5
6
  import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
6
7
  import { portableSnapshotSelection } from "../../sync/sync-policy.js";
7
8
  import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
9
+ import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
8
10
  import {
9
11
  type BackendDiagnostic,
10
12
  type ExpectedRemoteHead,
@@ -84,7 +86,7 @@ export class WebDavSyncBackend implements SyncBackend {
84
86
  await client.ensureCollection(snapshotsPath(this.config));
85
87
  const encoded = await encodeSnapshot(snapshot);
86
88
  throwIfAborted(options.signal);
87
- const pointer = pointerFor(this.config, snapshot, sha256(encoded));
89
+ const pointer = createLatestPointer(this.config.destination.namespace, snapshot, sha256(encoded));
88
90
  const stagedPath = snapshotPath(this.config, snapshot.id);
89
91
  try {
90
92
  await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
@@ -392,19 +394,6 @@ function webDavStorageLocation(config: ResolvedWebDavBackend) {
392
394
  return `${url.host} · ${config.destination.path}`;
393
395
  }
394
396
 
395
- function pointerFor(config: ResolvedWebDavBackend, snapshot: Snapshot, checksum: string): LatestPointer {
396
- return {
397
- version: VERSION,
398
- profile: config.destination.namespace,
399
- snapshot: snapshot.id,
400
- sha256: checksum,
401
- createdAt: snapshot.createdAt,
402
- machine: snapshot.machine,
403
- syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
404
- ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
405
- };
406
- }
407
-
408
397
  function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
409
398
  return {
410
399
  snapshotRef: pointer.snapshot,
@@ -490,16 +479,6 @@ function requireHistory(value: { version: number; snapshots: LatestPointer[] } |
490
479
  return pointers;
491
480
  }
492
481
 
493
- function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
494
- return {
495
- snapshotRef: pointer.snapshot,
496
- snapshotId: pointer.snapshot,
497
- createdAt: pointer.createdAt,
498
- machine: pointer.machine,
499
- syncSessions: pointer.syncSessions === true,
500
- };
501
- }
502
-
503
482
  function assertSnapshotIdentity(snapshot: Snapshot, namespace: string) {
504
483
  validatePortableSnapshot(snapshot);
505
484
  if (
@@ -637,11 +616,6 @@ function sha256(value: Buffer) {
637
616
  return createHash("sha256").update(value).digest("hex");
638
617
  }
639
618
 
640
- function throwIfAborted(signal?: AbortSignal) {
641
- if (!signal?.aborted) return;
642
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
643
- }
644
-
645
619
  function errorMessage(error: unknown) {
646
620
  if (error instanceof WebDavHttpError) return error.message;
647
621
  return error instanceof Error ? error.message : String(error);
@@ -1,4 +1,6 @@
1
1
  import { XMLParser } from "fast-xml-parser";
2
+ import { ContinuousError, continuousFailure, retryAfter } from "../../sync/continuous-errors.js";
3
+ import { throwIfAborted } from "../../sync/signals.js";
2
4
  import type { RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
3
5
 
4
6
  const JSON_LIMIT = 1024 * 1024;
@@ -24,6 +26,7 @@ export class WebDavHttpError extends Error {
24
26
  message: string,
25
27
  readonly status: number,
26
28
  options?: ErrorOptions,
29
+ readonly retryAfterMs?: number,
27
30
  ) {
28
31
  super(message, options);
29
32
  this.name = "WebDavHttpError";
@@ -231,7 +234,12 @@ export class WebDavClient {
231
234
  .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
232
235
  .trim();
233
236
  const detail = body ? `: ${redactText(body, this.config)}` : "";
234
- throw new WebDavHttpError(`WebDAV ${action} failed with HTTP ${response.status}${detail}`, response.status);
237
+ throw new WebDavHttpError(
238
+ `WebDAV ${action} failed with HTTP ${response.status}${detail}`,
239
+ response.status,
240
+ undefined,
241
+ retryAfter(response),
242
+ );
235
243
  }
236
244
  }
237
245
 
@@ -342,7 +350,8 @@ function safeUrl(url: URL) {
342
350
 
343
351
  function redactError(error: unknown, config: ResolvedWebDavBackend) {
344
352
  const message = error instanceof Error ? error.message : String(error);
345
- return new Error(redactText(message, config));
353
+ const failure = continuousFailure(error);
354
+ return new ContinuousError(failure.kind, redactText(message, config), failure.delay);
346
355
  }
347
356
 
348
357
  function redactText(value: string, config: ResolvedWebDavBackend) {
@@ -359,8 +368,3 @@ function redactText(value: string, config: ResolvedWebDavBackend) {
359
368
  function escapeRegExp(value: string) {
360
369
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
361
370
  }
362
-
363
- function throwIfAborted(signal?: AbortSignal) {
364
- if (!signal?.aborted) return;
365
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
366
- }
@@ -5,6 +5,7 @@ import { localConfigTemplate, readLocalConfigObject } from "../settings/settings
5
5
  import type { AnySyncConfig } from "../settings/settings-types.js";
6
6
  import { unlock, withLock } from "../state/lock.js";
7
7
  import { migrateLegacyStateDirectory, stateDirectoryMigrationNotice } from "../state/state-directory.js";
8
+ import { withAgentSyncLock } from "../sync/agent-sync.js";
8
9
  import { throwIfAborted } from "../sync/signals.js";
9
10
  import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
10
11
  import { isSyncDecisionRequiredError, SetupPullRequiresUiError } from "../sync/sync-errors.js";
@@ -49,7 +50,7 @@ export async function executeRecoveryCommand(
49
50
  const outcome = await withLock("pull", () => operations.pull(ctx, options));
50
51
  return { kind: "completed", ...(outcome ? { outcome } : {}) };
51
52
  }
52
- await withLock("sync", () => operations.syncBoth(ctx, options));
53
+ await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
53
54
  return { kind: "completed" };
54
55
  } catch (error) {
55
56
  if (signal?.aborted) return { kind: "failed" };
@@ -156,7 +157,7 @@ export async function executeCommand(
156
157
  case "sync": {
157
158
  const operations = await loaders.operations();
158
159
  throwIfAborted(options.signal);
159
- await withLock("sync", () => operations.syncBoth(ctx, options));
160
+ await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
160
161
  return { kind: "completed" };
161
162
  }
162
163
  case "history": {
@@ -258,6 +259,8 @@ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions)
258
259
  `settings field merge: ${config.mergeSettings ? "On (experimental)" : "Off"}`,
259
260
  `machine-local settings.json fields: ${(config.localFields ?? []).map(safeTerminalText).join(", ") || "none"}`,
260
261
  `startup transfer: ${startupTransferSummary(config)}`,
262
+ `agent mode: ${config.agentMode ? "On (experimental)" : "Off"}`,
263
+ `continuous sync: ${config.continuous ? (config.agentMode ? "On (experimental)" : "paused; Agent mode is Off") : "Off"}`,
261
264
  `included content: ${config.include.join(", ") || "none"}`,
262
265
  `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
263
266
  `settings file: ${localConfigPath()}`,
@@ -13,7 +13,7 @@ import {
13
13
  syncAttentionMatchesConfig,
14
14
  } from "../ui/sync-attention.js";
15
15
  import { setSyncStatus } from "../ui/sync-status.js";
16
- import { parseOptions, splitArgs } from "./command.js";
16
+ import { parseOptions, splitArgs, validateCommandOptions } from "./command.js";
17
17
  import { executeCommand, executeRecoveryCommand } from "./command-execution.js";
18
18
 
19
19
  export async function handleCommand(
@@ -22,6 +22,7 @@ export async function handleCommand(
22
22
  sessionSignal: AbortSignal,
23
23
  loaders: SyncLoaders,
24
24
  attention: SyncAttentionController,
25
+ onExplicitSync?: (setup?: string) => void,
25
26
  ) {
26
27
  await reconcileObservation(attention, sessionSignal);
27
28
  if (sessionSignal.aborted) return;
@@ -31,8 +32,18 @@ export async function handleCommand(
31
32
  onCommit?: () => void,
32
33
  target?: string,
33
34
  onDialog?: (active: boolean) => void,
34
- ) =>
35
- executeCommand(
35
+ ) => {
36
+ const [command, ...args] = splitArgs(route);
37
+ if (command === "sync") {
38
+ try {
39
+ const options = parseOptions(args);
40
+ validateCommandOptions("sync", options);
41
+ onExplicitSync?.(target ?? options.setup);
42
+ } catch {
43
+ /* Invalid routes do not request an automatic retry. */
44
+ }
45
+ }
46
+ return executeCommand(
36
47
  route,
37
48
  ctx,
38
49
  combineSignals(sessionSignal, signal),
@@ -41,6 +52,7 @@ export async function handleCommand(
41
52
  target,
42
53
  onDialog,
43
54
  );
55
+ };
44
56
  if (!rawArgs.trim()) {
45
57
  try {
46
58
  const { showSyncManager } = await import("../ui/manager-ui.js");
@@ -1,3 +1,5 @@
1
+ import type { AnySyncConfig } from "../settings/settings-types.js";
2
+ import type { AgentAcceptance } from "../sync/agent-request.js";
1
3
  import type { StartupTimings } from "../sync/startup-timings.js";
2
4
 
3
5
  export interface CommandOptions {
@@ -12,6 +14,10 @@ export interface CommandOptions {
12
14
  onCommit?: () => void;
13
15
  /** In-memory startup diagnostics only; never persisted or sent to a backend. */
14
16
  startupTimings?: StartupTimings;
17
+ /** Internal validated resolver result; never parsed from command arguments. */
18
+ agentAcceptance?: AgentAcceptance;
19
+ /** Internal cycle authorization, ownership and durable request reservation; never command arguments. */
20
+ background?: { validate(config?: AnySyncConfig): void; reserve(key: string): Promise<void> };
15
21
  args: string[];
16
22
  }
17
23
 
@@ -236,6 +236,7 @@ export function usage() {
236
236
  `Automatic sync: ${AUTOMATIC_SYNC_DESCRIPTION}`,
237
237
  `Startup transfer: ${STARTUP_TRANSFER_DESCRIPTION}`,
238
238
  "Automatic transfer at startup is a separate opt-in: one conflict-free established-baseline transfer at idle in TUI/RPC, with conditional/lease publication and no resource reload.",
239
+ "Experimental Continuous sync is a separate default-Off opt-in in Settings. It requires Agent mode, monitors while Pi is open, and can incur recurring network/model costs. Safety blocks and durable automatic request limits remain.",
239
240
  "Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
240
241
  ].join("\n");
241
242
  }
@@ -80,6 +80,8 @@ export async function loadPartialConfig(setupName?: string): Promise<PartialConf
80
80
  include: [...config.include],
81
81
  automatic: config.automatic,
82
82
  automaticTransfer: config.automaticTransfer ?? false,
83
+ agentMode: config.agentMode ?? false,
84
+ continuous: config.continuous ?? false,
83
85
  onSwitch: config.onSwitch,
84
86
  showStatus: config.showStatus,
85
87
  };
@@ -115,10 +117,13 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
115
117
  config.include,
116
118
  config.automatic,
117
119
  config.automaticTransfer ?? false,
120
+ config.agentMode ?? false,
118
121
  config.localFields,
119
122
  config.mergeSettings ?? false,
120
123
  config.mergeContent ?? false,
121
124
  config.partialSync ?? false,
125
+ // Preserve existing Off identities and cached candidates across the upgrade.
126
+ ...(config.continuous ? ["continuous"] : []),
122
127
  ]);
123
128
  }
124
129
 
@@ -166,6 +171,8 @@ function resolveSyncConfig(
166
171
  include,
167
172
  automatic: setup.sync.automatic,
168
173
  automaticTransfer: setup.sync.automaticTransfer ?? false,
174
+ agentMode: setup.sync.agentMode ?? false,
175
+ continuous: setup.sync.continuous ?? false,
169
176
  localFields: setup.sync.localFields === undefined ? undefined : normalizeLocalFields(setup.sync.localFields),
170
177
  mergeSettings: setup.sync.mergeSettings ?? false,
171
178
  mergeContent: setup.sync.mergeContent ?? false,
@@ -1,6 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
1
  import { localConfigPath, readMigratingLocalConfigDocument, updateLocalConfigDocument } from "./config-file.js";
5
2
  import type { OnSwitchAction, PiSyncSettingsV3 } from "./settings-types.js";
6
3
  import { DEFAULT_ON_SWITCH, validateSettingsDocument } from "./settings-validation.js";
@@ -63,34 +60,3 @@ export function updateLocalConfig(update: (current: PiSyncSettingsV3) => PiSyncS
63
60
  async function performLocalConfigUpdate(update: (current: PiSyncSettingsV3) => PiSyncSettingsV3, signal?: AbortSignal) {
64
61
  return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
65
62
  }
66
-
67
- export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
68
- validateSettingsDocument(value as Record<string, unknown>);
69
- const configPath = localConfigPath();
70
- await fs.mkdir(path.dirname(configPath), { recursive: true });
71
- try {
72
- const stat = await fs.lstat(configPath);
73
- if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
74
- if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
75
- } catch (error) {
76
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
77
- }
78
- const temporaryPath = path.join(
79
- path.dirname(configPath),
80
- `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
81
- );
82
- let handle: fs.FileHandle | undefined;
83
- try {
84
- handle = await fs.open(temporaryPath, "wx", 0o600);
85
- await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
86
- if (process.platform !== "win32") await handle.chmod(0o600);
87
- await handle.sync();
88
- await handle.close();
89
- handle = undefined;
90
- await fs.rename(temporaryPath, configPath);
91
- } catch (error) {
92
- await handle?.close().catch(() => undefined);
93
- await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
94
- throw error;
95
- }
96
- }
@@ -81,6 +81,10 @@ export interface SyncPolicySettings {
81
81
  automatic: boolean;
82
82
  /** Explicit opt-in, separate from the legacy observe/shutdown policy. */
83
83
  automaticTransfer?: boolean;
84
+ /** Experimental autonomous merged-sync authorization. */
85
+ agentMode?: boolean;
86
+ /** Separate explicit authorization for recurring background work. */
87
+ continuous?: boolean;
84
88
  /** Version 4: settings.json root fields never uploaded. */
85
89
  localFields?: string[];
86
90
  mergeSettings?: boolean;
@@ -116,6 +120,8 @@ export interface SyncConfig<Backend extends ResolvedSyncBackend = ResolvedS3Back
116
120
  include: string[];
117
121
  automatic: boolean;
118
122
  automaticTransfer?: boolean;
123
+ agentMode?: boolean;
124
+ continuous?: boolean;
119
125
  localFields?: string[];
120
126
  mergeSettings?: boolean;
121
127
  mergeContent?: boolean;
@@ -139,6 +145,8 @@ export interface PartialConfig {
139
145
  include: string[];
140
146
  automatic: boolean;
141
147
  automaticTransfer?: boolean;
148
+ agentMode?: boolean;
149
+ continuous?: boolean;
142
150
  onSwitch: OnSwitchAction;
143
151
  showStatus: boolean;
144
152
  bucket?: string;
@@ -219,7 +219,7 @@ function validateSyncSetup(name: string, value: Record<string, unknown>, connect
219
219
  const localFields = normalizeLocalFields(sync.localFields);
220
220
  if (localFields.length > 0 && !include.includes("settings.json"))
221
221
  throw new Error("Nonempty localFields requires settings.json in sync.include.");
222
- for (const key of ["mergeContent", "partialSync"])
222
+ for (const key of ["mergeContent", "partialSync", "agentMode", "continuous"])
223
223
  if (sync[key] !== undefined && typeof sync[key] !== "boolean") throw new Error(`${key} must be boolean.`);
224
224
  if (sync.mergeSettings !== undefined && typeof sync.mergeSettings !== "boolean")
225
225
  throw new Error("mergeSettings must be boolean.");
@@ -2,6 +2,7 @@ import { Readable, Writable } from "node:stream";
2
2
  import { pipeline } from "node:stream/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { createGunzip, gzip } from "node:zlib";
5
+ import { throwIfAborted } from "../sync/signals.js";
5
6
  import { snapshotSelectionInclude } from "../sync/sync-policy.js";
6
7
  import type { Snapshot } from "./snapshot-types.js";
7
8
 
@@ -47,8 +48,3 @@ export async function decodeSnapshot(
47
48
  snapshotSelectionInclude(parsed);
48
49
  return parsed;
49
50
  }
50
-
51
- function throwIfAborted(signal?: AbortSignal) {
52
- if (!signal?.aborted) return;
53
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
54
- }
package/src/state/lock.ts CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import lockfile from "proper-lockfile";
5
5
  import type { CommandOptions } from "../commands/command-types.js";
6
+ import { throwIfAborted } from "../sync/signals.js";
6
7
  import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
7
8
  import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
8
9
  import type { LockFile } from "./state-types.js";
@@ -57,8 +58,11 @@ export async function withLock<T>(
57
58
  inspection = { status: "missing" };
58
59
  }
59
60
  if (inspection.status === "valid") {
60
- throw new Error(
61
- `pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
61
+ throw Object.assign(
62
+ new Error(
63
+ `pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
64
+ ),
65
+ { code: "ELOCKED" },
62
66
  );
63
67
  }
64
68
  if (inspection.status === "unreadable") {
@@ -220,11 +224,6 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
220
224
  }
221
225
  }
222
226
 
223
- function throwIfAborted(signal?: AbortSignal) {
224
- if (!signal?.aborted) return;
225
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
226
- }
227
-
228
227
  async function releaseGuard(guard: Guard) {
229
228
  try {
230
229
  await guard.release();
@@ -261,11 +260,15 @@ async function describeHeldLock() {
261
260
  return new Error("pi-sync lock owner exited; retry shortly while the lock guard expires.");
262
261
  }
263
262
  if (current) {
264
- return new Error(
265
- `Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`,
263
+ return Object.assign(
264
+ new Error(`Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`),
265
+ { code: "ELOCKED" },
266
266
  );
267
267
  }
268
- return new Error("Pi-sync is currently running (lock metadata is unreadable or still being written).");
268
+ return Object.assign(
269
+ new Error("Pi-sync is currently running (lock metadata is unreadable or still being written)."),
270
+ { code: "ELOCKED" },
271
+ );
269
272
  }
270
273
 
271
274
  function isLockHeldError(error: unknown) {
@@ -66,7 +66,7 @@ export async function stageMergeBaseline(
66
66
  return (
67
67
  !collisions.has(file.path) &&
68
68
  accepted.lastFileHashes[file.path] === file.sha256 &&
69
- (file.path === "settings.json" || config.mergeContent) &&
69
+ (file.path === "settings.json" || config.mergeContent || (config.agentMode && isMergeTextPath(file.path))) &&
70
70
  eligible(file)
71
71
  );
72
72
  });
@@ -153,7 +153,7 @@ export async function hasCurrentMergeBaseline(config: AnySyncConfig, snapshot: S
153
153
  const hashes = new Map(ancestors.map((file) => [file.path, file.sha256]));
154
154
  return snapshot.files.every(
155
155
  (file) =>
156
- !(file.path === "settings.json" || config.mergeContent) ||
156
+ !(file.path === "settings.json" || config.mergeContent || (config.agentMode && isMergeTextPath(file.path))) ||
157
157
  !eligible(file) ||
158
158
  hashes.get(file.path) === file.sha256,
159
159
  );
@@ -79,7 +79,14 @@ export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPrepa
79
79
  realpath: false,
80
80
  stale: LOCK_GUARD_STALE_MS,
81
81
  });
82
- if (legacyGuardHeld || (await pathExists(legacyLock))) {
82
+ // Idle continuous owners do not retain the exclusive migration guard.
83
+ // A live renewable lease still prevents moving their private state root.
84
+ const schedulerHeld = await lockfile.check(path.join(legacyStateDir(), "continuous", "scheduler"), {
85
+ fs: LOCKFILE_FS_ADAPTER,
86
+ realpath: false,
87
+ stale: LOCK_GUARD_STALE_MS,
88
+ });
89
+ if (legacyGuardHeld || schedulerHeld || (await pathExists(legacyLock))) {
83
90
  return {
84
91
  status: "deferred",
85
92
  message: