@narumitw/pi-sync 0.54.3 → 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 (179) hide show
  1. package/README.md +21 -4
  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 +84 -1
  96. package/package.json +3 -2
  97. package/src/backends/git/git-backend.ts +1 -4
  98. package/src/backends/git/git-runner.ts +1 -5
  99. package/src/backends/s3/s3-backend.ts +3 -25
  100. package/src/backends/s3/s3-client.ts +14 -2
  101. package/src/backends/snapshot-pointer.ts +27 -0
  102. package/src/backends/webdav/webdav-backend.ts +3 -29
  103. package/src/backends/webdav/webdav-client.ts +11 -7
  104. package/src/commands/command-execution.ts +5 -2
  105. package/src/commands/command-handler.ts +15 -3
  106. package/src/commands/command-types.ts +6 -0
  107. package/src/commands/command.ts +1 -0
  108. package/src/settings/config.ts +7 -0
  109. package/src/settings/settings-store.ts +0 -34
  110. package/src/settings/settings-types.ts +8 -0
  111. package/src/settings/settings-validation.ts +1 -1
  112. package/src/snapshot/snapshot-codec.ts +1 -5
  113. package/src/state/lock.ts +13 -10
  114. package/src/state/merge-baseline-store.ts +2 -2
  115. package/src/state/state-directory.ts +8 -1
  116. package/src/sync/agent-candidate.ts +251 -0
  117. package/src/sync/agent-plan.ts +87 -0
  118. package/src/sync/agent-policy.ts +13 -0
  119. package/src/sync/agent-request.ts +17 -0
  120. package/src/sync/agent-runner.ts +188 -0
  121. package/src/sync/agent-store.ts +151 -0
  122. package/src/sync/agent-sync.ts +91 -0
  123. package/src/sync/automatic-sync.ts +2 -1
  124. package/src/sync/continuous-errors.ts +85 -0
  125. package/src/sync/continuous-lease.ts +221 -0
  126. package/src/sync/continuous-record.ts +179 -0
  127. package/src/sync/continuous-storage.ts +55 -0
  128. package/src/sync/continuous-sync.ts +516 -0
  129. package/src/sync/continuous-watch.ts +59 -0
  130. package/src/sync/merged-sync.ts +70 -12
  131. package/src/sync/remote-snapshot.ts +2 -3
  132. package/src/sync/settings-merge.ts +19 -3
  133. package/src/sync/setup-switch.ts +1 -5
  134. package/src/sync/startup-check.ts +15 -11
  135. package/src/sync/sync-inspection.ts +8 -1
  136. package/src/sync/sync-mutations.ts +6 -17
  137. package/src/sync/sync-queries.ts +5 -0
  138. package/src/sync/text-merge.ts +3 -3
  139. package/src/sync-extension.ts +75 -19
  140. package/src/ui/agent-mode-setting.ts +91 -0
  141. package/src/ui/manager-state.ts +2 -0
  142. package/src/ui/remote-selection-ui.ts +5 -5
  143. package/src/ui/settings-ui.ts +83 -1
  144. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  145. package/src/ui/setup/setup-name-ui.ts +2 -5
  146. package/src/ui/setup/setup-switcher.ts +2 -0
  147. package/src/ui/sync-setups-ui.ts +3 -5
  148. package/dist/chunks/chunk-26PMXY7E.ts +0 -362
  149. package/dist/chunks/chunk-26PMXY7E.ts.map +0 -7
  150. package/dist/chunks/chunk-35GM6XKM.ts.map +0 -7
  151. package/dist/chunks/chunk-A3TFXY4O.ts.map +0 -7
  152. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  153. package/dist/chunks/chunk-CAGJOKB7.ts.map +0 -7
  154. package/dist/chunks/chunk-E6ONFG57.ts.map +0 -7
  155. package/dist/chunks/chunk-GC3QOL6J.ts.map +0 -7
  156. package/dist/chunks/chunk-I4JYVFUS.ts.map +0 -7
  157. package/dist/chunks/chunk-KATKRR3D.ts.map +0 -7
  158. package/dist/chunks/chunk-XU2VXGQV.ts.map +0 -7
  159. package/dist/chunks/chunk-YTHQEW35.ts.map +0 -7
  160. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  161. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  162. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +0 -7
  163. package/dist/chunks/s3-backend-Y4P3O5B5.ts.map +0 -7
  164. package/dist/chunks/setup-wizard-VW7F3JN4.ts.map +0 -7
  165. package/dist/chunks/sync-operations-2CWST333.ts.map +0 -7
  166. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  167. /package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  168. /package/dist/chunks/{chunk-R35N4CL3.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  169. /package/dist/chunks/{chunk-KGE47GCZ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  170. /package/dist/chunks/{chunk-HMXUKU43.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  171. /package/dist/chunks/{chunk-AAZDBIBJ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  172. /package/dist/chunks/{chunk-2FETCGGT.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-VCL7TZYW.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  174. /package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  175. /package/dist/chunks/{merge-baseline-store-ICXVTCML.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  176. /package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  177. /package/dist/chunks/{file-selection-UNHBVVON.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-ZMW5C4M7.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  179. /package/dist/chunks/{sync-inspection-3EMS2J5B.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -37,6 +37,7 @@ import {
37
37
  import { confirmMergeReview } from "../ui/merge-review.js";
38
38
  import { mergedSyncSummary } from "../ui/merged-sync-summary.js";
39
39
  import { safeTerminalText } from "../ui/terminal-text.js";
40
+ import { startupTransferEnabled } from "./agent-policy.js";
40
41
  import { releaseConflictArtifacts } from "./conflict-artifact-ownership.js";
41
42
  import {
42
43
  type CreatedConflictArtifact,
@@ -82,7 +83,11 @@ export async function mergeSync(
82
83
  resolution?: ConflictResolution,
83
84
  ) {
84
85
  const timings = options.startupTimings ?? createStartupTimings();
85
- const validateOwner = captureMutationOwner(ctx, options.signal);
86
+ const sessionOwner = captureMutationOwner(ctx, options.signal);
87
+ const validateOwner = (config?: AnySyncConfig) => {
88
+ sessionOwner();
89
+ options.background?.validate(config);
90
+ };
86
91
  const isCurrent = () => {
87
92
  try {
88
93
  validateOwner();
@@ -92,7 +97,9 @@ export async function mergeSync(
92
97
  }
93
98
  };
94
99
  const config = await loadConfig(options.setup);
100
+ validateOwner(config);
95
101
  const configToken = syncCheckConfigFingerprint(config);
102
+ const modelToken = JSON.stringify(ctx.model);
96
103
  const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : undefined;
97
104
  validateOwner();
98
105
  const snapshotOptions = {
@@ -104,7 +111,7 @@ export async function mergeSync(
104
111
  timings.measure("transfer authorization", async () => {
105
112
  validateOwner();
106
113
  const latest = await loadConfig(options.setup);
107
- validateOwner();
114
+ validateOwner(latest);
108
115
  if (configToken !== syncCheckConfigFingerprint(latest))
109
116
  throw new Error("Sync setup or settings changed during transfer; reopen sync.");
110
117
  if (sessionRoot !== undefined) {
@@ -113,22 +120,29 @@ export async function mergeSync(
113
120
  if (currentRoot !== sessionRoot)
114
121
  throw new Error("Session storage root changed during transfer; evidence retained for review.");
115
122
  }
116
- if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
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()))
117
126
  throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
118
127
  });
119
128
  await validate();
120
129
  const backend = await timings.measure("transfer backend initialization", () => factory(config));
121
130
  await validate();
122
- if (options.auto && backend.capability === "read-check-write-verify") {
123
- throw new Error(
124
- "Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Use manual sync.",
125
- );
126
- }
127
131
  const pending = await timings.measure("transfer journal read", () => readMergeJournal(config));
128
132
  await validate();
129
133
  if (pending) {
130
134
  const recovered = await timings.measure("transfer journal recovery", () =>
131
- completeJournal(ctx, config, backend, pending, validate, timings, options.signal, options.auto),
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
+ ),
132
146
  );
133
147
  if (!recovered) {
134
148
  if ((options.auto || !options.silent) && !options.signal?.aborted)
@@ -142,6 +156,12 @@ export async function mergeSync(
142
156
  ctx.ui.notify("Recovered the committed merged transfer. Run sync again to inspect newer changes.", "info");
143
157
  return "applied" as const;
144
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
+ }
145
165
  const textBudget = { remaining: TEXT_MERGE_WORK_LIMIT };
146
166
  const createdArtifacts: CreatedConflictArtifact[] = [];
147
167
  const ownedStatePath = statePathForConfig(config);
@@ -300,7 +320,33 @@ export async function mergeSync(
300
320
  );
301
321
  await validate();
302
322
  }
303
- 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
+ ) {
304
350
  const decision = review(
305
351
  selectionCompatible
306
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(", ")}` : ""}`
@@ -467,7 +513,7 @@ export async function mergeSync(
467
513
  await validate();
468
514
  if (!config.skipSecretScan && scanSnapshot(upload).length)
469
515
  throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
470
- if (!options.yes) {
516
+ if (!options.yes && !config.agentMode) {
471
517
  const summary = mergedSyncSummary({
472
518
  setupName: config.setupName,
473
519
  destination: backend.destination,
@@ -585,7 +631,17 @@ export async function mergeSync(
585
631
  throw mergeTransferError(error, config.backend);
586
632
  }
587
633
  const completed = await timings.measure("transfer journal recovery", () =>
588
- completeJournal(ctx, config, backend, journal, validate, timings, options.signal, options.auto),
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
+ ),
589
645
  );
590
646
  if (!completed) {
591
647
  if (!options.signal?.aborted && (options.auto || !options.silent))
@@ -624,6 +680,7 @@ async function completeJournal(
624
680
  timings: StartupTimings,
625
681
  signal?: AbortSignal,
626
682
  auto = false,
683
+ validateBackground?: () => void,
627
684
  ) {
628
685
  if (
629
686
  journal.identity !== mergeJournalIdentity(config, backend.identity) ||
@@ -734,6 +791,7 @@ async function completeJournal(
734
791
  const validateOwner = captureMutationOwner(ctx, signal);
735
792
  const validateMutation = () => {
736
793
  validateOwner();
794
+ validateBackground?.();
737
795
  if (auto && !ctx.isIdle()) throw new Error("Automatic file apply is no longer idle; evidence retained.");
738
796
  };
739
797
  await timings.measure("transfer local apply", () =>
@@ -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}.`);
@@ -10,7 +10,7 @@ export type SettingsMergeResult =
10
10
  | { kind: "merged"; content: Buffer }
11
11
  | { kind: "review"; reason: "unsupported-format" | "field-conflict"; fields: string[] };
12
12
 
13
- const COUPLED_FIELDS = [
13
+ export const COUPLED_FIELDS = [
14
14
  ["defaultProvider", "defaultModel"],
15
15
  ["queueMode", "steeringMode"],
16
16
  ["websockets", "transport"],
@@ -19,7 +19,12 @@ const COUPLED_FIELDS = [
19
19
  ] as const;
20
20
 
21
21
  /** Only global settings.json; objects/arrays are atomic units until their invariants are proven. */
22
- export function mergeSettingsJson(ancestor: Buffer, local: Buffer, remote: Buffer): SettingsMergeResult {
22
+ export function mergeSettingsJson(
23
+ ancestor: Buffer,
24
+ local: Buffer,
25
+ remote: Buffer,
26
+ selections: ReadonlyMap<string, "local" | "remote"> = new Map(),
27
+ ): SettingsMergeResult {
23
28
  let documents: JsonDocument[];
24
29
  try {
25
30
  documents = [ancestor, local, remote].map(parseSettingsDocument);
@@ -38,7 +43,18 @@ export function mergeSettingsJson(ancestor: Buffer, local: Buffer, remote: Buffe
38
43
  unit.every((field) =>
39
44
  jsonEqual(first.root.members.get(field)?.node.value, second.root.members.get(field)?.node.value),
40
45
  );
41
- const source = equal(left, right) ? left : equal(left, base) ? right : equal(right, base) ? left : undefined;
46
+ const choice = selections.get(unit[0] ?? "");
47
+ const source = equal(left, right)
48
+ ? left
49
+ : equal(left, base)
50
+ ? right
51
+ : equal(right, base)
52
+ ? left
53
+ : choice === "local"
54
+ ? left
55
+ : choice === "remote"
56
+ ? right
57
+ : undefined;
42
58
  if (!source) {
43
59
  conflicts.push(...unit.filter((field) => fields.has(field)));
44
60
  continue;
@@ -5,6 +5,7 @@ import { configuredSyncSetupNames, updateLocalConfig } from "../settings/setting
5
5
  import type { OnSwitchAction } from "../settings/settings-types.js";
6
6
  import { normalizeOnSwitch } from "../settings/settings-validation.js";
7
7
  import { safeTerminalText } from "../ui/terminal-text.js";
8
+ import { throwIfAborted } from "./signals.js";
8
9
  import { SetupPullRequiresUiError } from "./sync-errors.js";
9
10
 
10
11
  export { SetupPullRequiresUiError } from "./sync-errors.js";
@@ -128,8 +129,3 @@ export async function useSyncSetup(
128
129
  }
129
130
  return { pullApplied: pullOutcome === "applied" };
130
131
  }
131
-
132
- function throwIfAborted(signal?: AbortSignal) {
133
- if (!signal?.aborted) return;
134
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
135
- }
@@ -9,6 +9,8 @@ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-st
9
9
  import type { SyncAttentionController } from "../ui/sync-attention.js";
10
10
  import { setSyncStatus } from "../ui/sync-status.js";
11
11
  import { safeTerminalText } from "../ui/terminal-text.js";
12
+ import { startupTransferEnabled } from "./agent-policy.js";
13
+ import { withAgentSyncLock } from "./agent-sync.js";
12
14
  import { combineSignals, throwIfAborted } from "./signals.js";
13
15
  import { createStartupTimings } from "./startup-timings.js";
14
16
  import { errorMessage } from "./sync-errors.js";
@@ -42,7 +44,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
42
44
  },
43
45
  start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
44
46
  if (!ctx.hasUI || sessionSignal.aborted) return;
45
- if (initialConfig?.automaticTransfer && !ctx.isIdle()) {
47
+ if (initialConfig && startupTransferEnabled(initialConfig) && !ctx.isIdle()) {
46
48
  pending = { ctx, signal: sessionSignal, config: initialConfig };
47
49
  return;
48
50
  }
@@ -54,7 +56,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
54
56
  controller,
55
57
  owner: ctx.sessionManager,
56
58
  promise: Promise.resolve(),
57
- transfer: Boolean(initialConfig?.automaticTransfer),
59
+ transfer: Boolean(initialConfig && startupTransferEnabled(initialConfig)),
58
60
  };
59
61
  active = task;
60
62
  const isCurrent = () => !sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
@@ -71,9 +73,9 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
71
73
  try {
72
74
  throwIfAborted(signal);
73
75
  config = initialConfig ?? (await loadConfigForCheck());
74
- task.transfer = Boolean(config.automaticTransfer);
76
+ task.transfer = startupTransferEnabled(config);
75
77
  throwIfAborted(signal);
76
- if (!config.automatic && !config.automaticTransfer) return;
78
+ if (!config.automatic && !startupTransferEnabled(config)) return;
77
79
  const captured = config;
78
80
  const identity = syncCheckConfigFingerprint(captured);
79
81
  checking = true;
@@ -106,10 +108,10 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
106
108
  configIdentity: identity,
107
109
  checkedAt: new Date().toISOString(),
108
110
  inspection,
109
- automaticTransfer: captured.automaticTransfer,
111
+ automaticTransfer: startupTransferEnabled(captured),
110
112
  });
111
113
  if (
112
- captured.automaticTransfer &&
114
+ startupTransferEnabled(captured) &&
113
115
  !inspection.firstSync &&
114
116
  inspection.head &&
115
117
  inspection.selectionState?.kind === "same"
@@ -123,8 +125,9 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
123
125
  }
124
126
  await timings.measure("automatic transfer", () =>
125
127
  withStateDirectoryAccess(() =>
126
- withLock("startup-transfer", () =>
127
- operations.syncBoth(ctx, {
128
+ withAgentSyncLock(
129
+ ctx,
130
+ {
128
131
  args: [],
129
132
  yes: true,
130
133
  force: false,
@@ -135,7 +138,8 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
135
138
  signal,
136
139
  startupTimings: timings,
137
140
  onCommit: () => attention.clearObservation(),
138
- }),
141
+ },
142
+ (options) => operations.syncBoth(ctx, options),
139
143
  ),
140
144
  ),
141
145
  );
@@ -149,7 +153,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
149
153
  configIdentity: identity,
150
154
  checkedAt: new Date().toISOString(),
151
155
  inspection,
152
- automaticTransfer: captured.automaticTransfer,
156
+ automaticTransfer: startupTransferEnabled(captured),
153
157
  });
154
158
  if (ctx.mode === "rpc") attention.notifyObservation(ctx);
155
159
  } catch (error) {
@@ -163,7 +167,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
163
167
  if (!latest || syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)) return;
164
168
  }
165
169
  ctx.ui.notify(
166
- config?.automaticTransfer
170
+ config && startupTransferEnabled(config)
167
171
  ? `pi-sync automatic transfer stopped: ${safeTerminalText(errorMessage(error))}. Run /sync to review or reconcile retained recovery evidence. No resources were reloaded.`
168
172
  : `pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
169
173
  "warning",
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
2
3
  import type { AnySyncConfig } from "../settings/settings-types.js";
3
4
  import { createSnapshot } from "../snapshot/snapshot.js";
@@ -6,7 +7,7 @@ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-st
6
7
  import { throwIfAborted } from "./signals.js";
7
8
  import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
8
9
  import { inspectRemoteSelection } from "./sync-policy.js";
9
- import { hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
10
+ import { fileHashMap, hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
10
11
 
11
12
  /** Advisory baseline-relative observation, never an authorization to transfer files. */
12
13
  export async function inspectSync(
@@ -27,7 +28,13 @@ export async function inspectSync(
27
28
  throwIfAborted(signal);
28
29
  const head = await timings.measure("remote head", () => backend.readHead(signal));
29
30
  throwIfAborted(signal);
31
+ const freshness: { localIdentity?: string } = {
32
+ localIdentity: createHash("sha256")
33
+ .update(JSON.stringify(fileHashMap(local)))
34
+ .digest("hex"),
35
+ };
30
36
  return {
37
+ ...freshness,
31
38
  head,
32
39
  selectionState: head ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] }) : undefined,
33
40
  localFiles: local.files.length,
@@ -32,6 +32,7 @@ import {
32
32
  } from "../ui/sync-format.js";
33
33
  import { setSyncStatus } from "../ui/sync-status.js";
34
34
  import { confirmTransferReview } from "../ui/transfer-review.js";
35
+ import { startupTransferEnabled } from "./agent-policy.js";
35
36
  import { inspectCaseCleanup, revalidateCaseCleanup } from "./apply-review.js";
36
37
  import { confirmFieldMigration } from "./field-migration.js";
37
38
  import { overlayLocalFields, portableSnapshot, sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
@@ -429,7 +430,9 @@ export async function syncBoth(
429
430
  const validateMutation = captureMutationOwner(ctx, options.signal);
430
431
  const config = await loadConfig(options.setup);
431
432
  throwIfAborted(options.signal);
432
- if (config.include.length > 0 && options.auto && config.automaticTransfer) return mergeSync(ctx, options, factory);
433
+ options.background?.validate(config);
434
+ if (config.include.length > 0 && options.auto && startupTransferEnabled(config))
435
+ return mergeSync(ctx, options, factory);
433
436
  if (!options.auto && config.include.length > 0 && (await readMergeJournal(config)))
434
437
  return mergeSync(ctx, options, factory);
435
438
  const state = await readStateForConfig(config);
@@ -451,15 +454,13 @@ export async function syncBoth(
451
454
  throwIfAborted(options.signal);
452
455
  const localChanged = hasLocalChanges(local, state, config);
453
456
  const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
454
- const firstSync = !state.lastAppliedSnapshot;
455
457
  if (options.auto) throw new Error("Automatic transfer is not authorized by the current settings.");
456
458
 
459
+ // Established baselines return through mergeSync above; only initial-source selection remains.
457
460
  const portableLocal = portableSnapshot(local, config.localFields);
458
461
  const acceptMatchingState = async (snapshot: Snapshot) => {
459
462
  requireCompatibleRemoteSelection(config, snapshot);
460
463
  const accepted = portableSnapshot(snapshot, config.localFields);
461
- if (state.lastAppliedSnapshot && !sameLocalFields(state.localFields, config.localFields))
462
- throw new Error("Local-field rules changed; review and confirm a directional migration first.");
463
464
  await writeStateForConfig(
464
465
  config,
465
466
  {
@@ -477,7 +478,7 @@ export async function syncBoth(
477
478
  await pruneMergeBaselines(config, await readStateForConfig(config), validateMutation);
478
479
  validateMutation();
479
480
  };
480
- if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
481
+ if (remote && remote.files.length > 0 && local.files.length > 0) {
481
482
  requireCompatibleRemoteSelection(config, remote);
482
483
  const portableRemote = portableSnapshot(remote, config.localFields);
483
484
  if (!canPullRemoteSettingsOnFirstSync(portableLocal, portableRemote)) {
@@ -525,18 +526,6 @@ export async function syncBoth(
525
526
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
526
527
  return;
527
528
  }
528
- if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
529
- throw createSyncDecision({
530
- kind: "both-changed",
531
- config,
532
- state,
533
- local,
534
- remote,
535
- localChanged,
536
- remoteChanged,
537
- directMessage: "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
538
- });
539
- }
540
529
  if (remoteChanged) {
541
530
  await pull(ctx, options, factory);
542
531
  return;
@@ -36,6 +36,10 @@ export async function status(
36
36
  );
37
37
  throwIfAborted(options.signal);
38
38
 
39
+ const continuous = config.continuous
40
+ ? await (await import("./continuous-record.js")).continuousStatusLines(config)
41
+ : [];
42
+ throwIfAborted(options.signal);
39
43
  const remoteText = head ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}` : "remote: empty";
40
44
  const warnings = syncSessionsWarnings(config);
41
45
  setSyncStatus(ctx, undefined);
@@ -47,6 +51,7 @@ export async function status(
47
51
  `publication safety: ${publicationCapabilityDescription(capability)}`,
48
52
  `included content: ${config.include.join(", ") || "none"}`,
49
53
  `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
54
+ ...continuous,
50
55
  remoteText,
51
56
  formatRemoteSelectionStatus(selectionState),
52
57
  `local files: ${localFiles}`,
@@ -30,11 +30,11 @@ export function text(bytes: Buffer) {
30
30
  throw new Error("Unsupported text encoding.");
31
31
  return decoded;
32
32
  }
33
- type Hunk = { start: number; end: number; lines: string[] };
34
- function lines(value: string) {
33
+ export type Hunk = { start: number; end: number; lines: string[] };
34
+ export function lines(value: string) {
35
35
  return value.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
36
36
  }
37
- function edits(base: string[], next: string[], signal?: AbortSignal): Hunk[] {
37
+ export function edits(base: string[], next: string[], signal?: AbortSignal): Hunk[] {
38
38
  const width = next.length + 1;
39
39
  if ((base.length + 1) * width > 4_000_000) throw new Error("Text merge complexity bound exceeded.");
40
40
  // Intern once so each charged DP cell compares fixed-size IDs, not long line strings.
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { completeSyncArguments, splitArgs } from "./commands/command.js";
3
3
  import { handleCommand } from "./commands/command-handler.js";
4
4
  import type { AnySyncConfig } from "./settings/settings-types.js";
@@ -19,6 +19,31 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
19
19
  let sessionAbort = new AbortController();
20
20
  let shutdownAbort: AbortController | undefined;
21
21
  let initialization = Promise.resolve<AnySyncConfig | false | undefined>(undefined);
22
+ let continuous: ReturnType<typeof import("./sync/continuous-sync.js")["createContinuousSync"]> | undefined;
23
+ let owner: ExtensionContext["sessionManager"] | undefined;
24
+ let continuousPossible = false;
25
+ const foreground = new Map<ExtensionContext["sessionManager"], number>();
26
+ async function resumeContinuous(ctx: ExtensionContext, signal: AbortSignal, retry?: { setup?: string }) {
27
+ const isCurrent = () => !signal.aborted && !foreground.has(ctx.sessionManager) && owner === ctx.sessionManager;
28
+ if (!isCurrent() || !ctx.hasUI) return;
29
+ try {
30
+ const { loadConfigForCheck } = await import("./settings/config.js");
31
+ if (!isCurrent()) return;
32
+ const config = await loadConfigForCheck();
33
+ if (!isCurrent()) return;
34
+ continuousPossible = Boolean(config.continuous && config.agentMode);
35
+ if (!continuousPossible) {
36
+ await continuous?.stop(ctx.sessionManager);
37
+ return;
38
+ }
39
+ const { createContinuousSync } = await import("./sync/continuous-sync.js");
40
+ if (!isCurrent()) return;
41
+ continuous ??= createContinuousSync(loaders);
42
+ continuous.start(ctx, signal, Boolean(retry && (retry.setup === undefined || retry.setup === config.setupName)));
43
+ } catch {
44
+ /* Existing initialization/command paths report settings errors. */
45
+ }
46
+ }
22
47
 
23
48
  pi.registerCommand("sync", {
24
49
  description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
@@ -28,23 +53,39 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
28
53
  throw new Error("/sync requires TUI or RPC mode so results and safety prompts are observable.");
29
54
  }
30
55
  const signal = sessionAbort.signal;
31
- const ready = await initialization;
32
- if (signal.aborted) return;
33
- await check.stop();
34
- if (signal.aborted) return;
56
+ const manager = ctx.sessionManager;
35
57
  const command = splitArgs(args)[0];
36
- if (ready === false && command !== "help" && command !== "unlock" && command !== "migrate-state") {
37
- ctx.ui.notify(
38
- "pi-sync recovery required. Repair the startup error, then /reload before syncing. Use /sync help for recovery guidance.",
39
- "error",
40
- );
41
- return;
58
+ let retry: { setup?: string } | undefined;
59
+ let mayResume = false;
60
+ foreground.set(manager, (foreground.get(manager) ?? 0) + 1);
61
+ try {
62
+ const ready = await initialization;
63
+ mayResume = ready !== false;
64
+ if (signal.aborted) return;
65
+ await continuous?.stop(manager);
66
+ if (signal.aborted) return;
67
+ await check.stop();
68
+ if (signal.aborted) return;
69
+ if (ready === false && command !== "help" && command !== "unlock" && command !== "migrate-state") {
70
+ ctx.ui.notify(
71
+ "pi-sync recovery required. Repair the startup error, then /reload before syncing. Use /sync help for recovery guidance.",
72
+ "error",
73
+ );
74
+ return;
75
+ }
76
+ const run = () =>
77
+ handleCommand(args, ctx, signal, loaders, attention, (setup) => {
78
+ retry = { setup };
79
+ });
80
+ // Drain background children before foreground guards. Help remains reachable during repair.
81
+ if (command === "migrate-state" || command === "help") await run();
82
+ else await withStateDirectoryAccess(run);
83
+ } finally {
84
+ const count = (foreground.get(manager) ?? 1) - 1;
85
+ if (count) foreground.set(manager, count);
86
+ else foreground.delete(manager);
87
+ if (mayResume) await resumeContinuous(ctx, signal, retry);
42
88
  }
43
- const run = () => handleCommand(args, ctx, signal, loaders, attention);
44
- // Drain background cache/child cleanup before entering any foreground guard.
45
- // Help must remain reachable when the state roots themselves need repair.
46
- if (command === "migrate-state" || command === "help") await run();
47
- else await withStateDirectoryAccess(run);
48
89
  },
49
90
  });
50
91
 
@@ -54,11 +95,15 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
54
95
  sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
55
96
  sessionAbort = new AbortController();
56
97
  const signal = sessionAbort.signal;
98
+ owner = ctx.sessionManager;
99
+ continuousPossible = false;
57
100
  attention.reset(ctx);
58
101
  const previous = initialization;
59
102
  initialization = (async () => {
60
103
  await previous;
61
104
  if (signal.aborted) return false;
105
+ await continuous?.stop();
106
+ if (signal.aborted) return false;
62
107
  await check.stop();
63
108
  if (signal.aborted) return false;
64
109
  try {
@@ -72,18 +117,27 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
72
117
  }
73
118
  })();
74
119
  const ready = await initialization;
75
- if (ready && !signal.aborted) check.start(ctx, signal, ready);
120
+ if (ready && !signal.aborted) {
121
+ if (ready.continuous && ready.agentMode) await resumeContinuous(ctx, signal);
122
+ else check.start(ctx, signal, ready);
123
+ }
76
124
  });
77
125
 
78
126
  pi.on("agent_start", async (_event, ctx) => {
127
+ await continuous?.stop(ctx.sessionManager);
79
128
  await check.interrupt(ctx);
80
129
  });
81
130
 
82
- pi.on("agent_settled", (_event, ctx) => {
83
- if (!sessionAbort.signal.aborted) check.settled(ctx);
131
+ pi.on("agent_settled", async (_event, ctx) => {
132
+ const signal = sessionAbort.signal;
133
+ if (!signal.aborted) {
134
+ check.settled(ctx);
135
+ if (continuousPossible) await resumeContinuous(ctx, signal);
136
+ }
84
137
  });
85
138
 
86
139
  pi.on("session_shutdown", async (event, ctx) => {
140
+ if (continuous && owner && owner !== ctx.sessionManager) return;
87
141
  sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
88
142
  attention.reset(ctx);
89
143
  shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
@@ -94,6 +148,8 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
94
148
  try {
95
149
  const ready = await initialization;
96
150
  if (signal.aborted) return;
151
+ await continuous?.stop(ctx.sessionManager);
152
+ if (signal.aborted) return;
97
153
  await check.stop();
98
154
  if (signal.aborted) return;
99
155
  if (ready !== false && reason !== "reload") {