@narumitw/pi-sync 0.57.10 → 0.57.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -1
  3. package/dist/chunks/{agent-candidate-EYB35AGL.ts → agent-candidate-OCDPRI5G.ts} +11 -11
  4. package/dist/chunks/{agent-plan-KO43GTOR.ts → agent-plan-P5B5RGZ7.ts} +14 -14
  5. package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts → agent-refusal-retry-476YGNU5.ts} +10 -10
  6. package/dist/chunks/{agent-store-UT52DS7B.ts → agent-store-RJSMNOYP.ts} +7 -7
  7. package/dist/chunks/{agent-sync-VM3TXWA3.ts → agent-sync-EUK2CPSS.ts} +6 -6
  8. package/dist/chunks/{chunk-UCD7TQDT.ts → chunk-3IYAPMST.ts} +4 -4
  9. package/dist/chunks/{chunk-JXWTEJS5.ts → chunk-45COPAK7.ts} +10 -3
  10. package/dist/chunks/chunk-45COPAK7.ts.map +7 -0
  11. package/dist/chunks/{chunk-DOJRPBQU.ts → chunk-5TSCBBPP.ts} +4 -4
  12. package/dist/chunks/{chunk-IMMMZUFC.ts → chunk-67INJPKR.ts} +4 -4
  13. package/dist/chunks/{chunk-CDQGSZL3.ts → chunk-6LD5KX55.ts} +6 -6
  14. package/dist/chunks/{chunk-MDUORMCK.ts → chunk-6O3EDBRL.ts} +2 -2
  15. package/dist/chunks/{chunk-AAPAHANE.ts → chunk-7A35ZAVO.ts} +320 -219
  16. package/dist/chunks/chunk-7A35ZAVO.ts.map +7 -0
  17. package/dist/chunks/{chunk-2VSN3MFX.ts → chunk-7OLLPPLU.ts} +29 -23
  18. package/dist/chunks/chunk-7OLLPPLU.ts.map +7 -0
  19. package/dist/chunks/{chunk-STVQQ5KG.ts → chunk-7ZIYU5QS.ts} +6 -6
  20. package/dist/chunks/{chunk-AO6EDUA6.ts → chunk-AKMV47YC.ts} +3 -3
  21. package/dist/chunks/{chunk-66GAUVDL.ts → chunk-BEST45H2.ts} +6 -6
  22. package/dist/chunks/{chunk-DO55XUNU.ts → chunk-CBLTWXAU.ts} +15 -5
  23. package/dist/chunks/chunk-CBLTWXAU.ts.map +7 -0
  24. package/dist/chunks/{chunk-PWA3YN5Z.ts → chunk-CPT2BN6N.ts} +2 -2
  25. package/dist/chunks/{chunk-73MDIVYJ.ts → chunk-DN4IA7MR.ts} +6 -6
  26. package/dist/chunks/{chunk-4RLATUP4.ts → chunk-DRTC4PKM.ts} +6 -6
  27. package/dist/chunks/{chunk-PHANBTVM.ts → chunk-ESKOPQVB.ts} +4 -4
  28. package/dist/chunks/{chunk-ODF2M4RF.ts → chunk-F7E3VFXU.ts} +5 -5
  29. package/dist/chunks/{chunk-YZ5BQNRZ.ts → chunk-FHE2DCA2.ts} +2 -2
  30. package/dist/chunks/{chunk-YZ5BQNRZ.ts.map → chunk-FHE2DCA2.ts.map} +2 -2
  31. package/dist/chunks/{chunk-MRIPZGPR.ts → chunk-FLHCOJCD.ts} +3 -3
  32. package/dist/chunks/{chunk-5WJQ6XOW.ts → chunk-GNYGNHFF.ts} +2 -2
  33. package/dist/chunks/{chunk-DFJGTVPX.ts → chunk-KYTT7KV4.ts} +2 -2
  34. package/dist/chunks/{chunk-UGSSZWQ6.ts → chunk-MKSMJPVA.ts} +2 -2
  35. package/dist/chunks/{chunk-RAMHGOX2.ts → chunk-OJARL2TG.ts} +4 -4
  36. package/dist/chunks/{chunk-BJXSCGQK.ts → chunk-OKPPZA4D.ts} +148 -89
  37. package/dist/chunks/chunk-OKPPZA4D.ts.map +7 -0
  38. package/dist/chunks/{chunk-RX3WGWVL.ts → chunk-OVBAW7KV.ts} +177 -130
  39. package/dist/chunks/chunk-OVBAW7KV.ts.map +7 -0
  40. package/dist/chunks/{chunk-U77DWNRH.ts → chunk-Q23JJW7E.ts} +3 -3
  41. package/dist/chunks/{chunk-QWV3XKNJ.ts → chunk-TIMWN2RH.ts} +5 -5
  42. package/dist/chunks/{chunk-Z5O2TLNF.ts → chunk-TZC7GDJE.ts} +7 -7
  43. package/dist/chunks/{chunk-TO2GMX3K.ts → chunk-XJHQPB3E.ts} +2 -2
  44. package/dist/chunks/{chunk-JY2RVOUL.ts → chunk-Y4UVXOLQ.ts} +5 -5
  45. package/dist/chunks/{chunk-M5IV3KL2.ts → chunk-ZDDBZH72.ts} +3 -3
  46. package/dist/chunks/{command-handler-FO6F2APH.ts → command-handler-F2E7A5T5.ts} +22 -17
  47. package/dist/chunks/command-handler-F2E7A5T5.ts.map +7 -0
  48. package/dist/chunks/{config-B37W7FXW.ts → config-HYG7G5EM.ts} +6 -4
  49. package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts → conflict-artifacts-J6WSDCWP.ts} +12 -12
  50. package/dist/chunks/{conflict-review-BQSHSJB3.ts → conflict-review-2QMO2K6Q.ts} +23 -23
  51. package/dist/chunks/{continuous-record-4DQ2EAZS.ts → continuous-record-AH7JGYKM.ts} +6 -6
  52. package/dist/chunks/{continuous-sync-ZT3UEQLK.ts → continuous-sync-VRMXCA5X.ts} +18 -18
  53. package/dist/chunks/{file-selection-FBLKM25V.ts → file-selection-IELTXSGZ.ts} +6 -7
  54. package/dist/chunks/{file-selection-FBLKM25V.ts.map → file-selection-IELTXSGZ.ts.map} +1 -1
  55. package/dist/chunks/{git-backend-7PCWECRN.ts → git-backend-KHD36W27.ts} +6 -7
  56. package/dist/chunks/{git-backend-7PCWECRN.ts.map → git-backend-KHD36W27.ts.map} +1 -1
  57. package/dist/chunks/{local-fields-B6JWLPAE.ts → local-fields-JEKAPZKD.ts} +6 -7
  58. package/dist/chunks/manager-result-dispatcher-6W52NM7F.ts +32 -0
  59. package/dist/chunks/{manager-ui-DGRTG76I.ts → manager-ui-LM7DSJ2Y.ts} +103 -52
  60. package/dist/chunks/manager-ui-LM7DSJ2Y.ts.map +7 -0
  61. package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts → merge-baseline-store-65GKUMXI.ts} +6 -6
  62. package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts → remote-selection-ui-TD4C4W3J.ts} +16 -12
  63. package/dist/chunks/{s3-backend-CJXBEWWU.ts → s3-backend-L24UZ6IK.ts} +12 -10
  64. package/dist/chunks/s3-backend-L24UZ6IK.ts.map +7 -0
  65. package/dist/chunks/{setup-switch-66GWMBNH.ts → setup-switch-PIPXYPCW.ts} +5 -5
  66. package/dist/chunks/{setup-wizard-OHZXJVQZ.ts → setup-wizard-RR5U3SUB.ts} +6 -6
  67. package/dist/chunks/{snapshot-VRHNAUUH.ts → snapshot-RGNDYT6A.ts} +5 -6
  68. package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts → snapshot-transaction-26BQO36S.ts} +8 -8
  69. package/dist/chunks/{sync-inspection-4WFQEJOE.ts → sync-inspection-MOMLGUTD.ts} +11 -11
  70. package/dist/chunks/{sync-operations-X4AOXMBT.ts → sync-operations-CV3TULLD.ts} +25 -25
  71. package/dist/chunks/{sync-state-SWLSMK4G.ts → sync-state-7L4NT7W2.ts} +7 -8
  72. package/dist/chunks/{webdav-backend-2Q5ZEBST.ts → webdav-backend-3ZCACILP.ts} +7 -8
  73. package/dist/chunks/{webdav-backend-2Q5ZEBST.ts.map → webdav-backend-3ZCACILP.ts.map} +1 -1
  74. package/dist/index.ts +19 -19
  75. package/package.json +1 -1
  76. package/skills/configure-pi-sync/references/configuration.md +8 -1
  77. package/src/backends/s3/s3-client.ts +8 -1
  78. package/src/commands/command-execution.ts +8 -3
  79. package/src/paths.ts +6 -1
  80. package/src/settings/config-file.ts +205 -118
  81. package/src/settings/config.ts +1 -1
  82. package/src/settings/settings-management.ts +15 -1
  83. package/src/settings/settings-store.ts +0 -14
  84. package/src/snapshot/session-paths.ts +15 -2
  85. package/src/snapshot/snapshot-mutation-queues.ts +142 -0
  86. package/src/snapshot/snapshot-transaction.ts +122 -125
  87. package/src/sync/merge-apply.ts +6 -0
  88. package/src/ui/cancellable-operation.ts +36 -13
  89. package/src/ui/manager-state.ts +3 -1
  90. package/src/ui/manager-ui.ts +76 -24
  91. package/src/ui/remote-selection-ui.ts +131 -82
  92. package/src/ui/settings-ui.ts +2 -1
  93. package/dist/chunks/chunk-2VSN3MFX.ts.map +0 -7
  94. package/dist/chunks/chunk-AAPAHANE.ts.map +0 -7
  95. package/dist/chunks/chunk-BJXSCGQK.ts.map +0 -7
  96. package/dist/chunks/chunk-DO55XUNU.ts.map +0 -7
  97. package/dist/chunks/chunk-JXWTEJS5.ts.map +0 -7
  98. package/dist/chunks/chunk-RX3WGWVL.ts.map +0 -7
  99. package/dist/chunks/command-handler-FO6F2APH.ts.map +0 -7
  100. package/dist/chunks/manager-result-dispatcher-NODX3KKH.ts +0 -28
  101. package/dist/chunks/manager-ui-DGRTG76I.ts.map +0 -7
  102. package/dist/chunks/s3-backend-CJXBEWWU.ts.map +0 -7
  103. /package/dist/chunks/{agent-candidate-EYB35AGL.ts.map → agent-candidate-OCDPRI5G.ts.map} +0 -0
  104. /package/dist/chunks/{agent-plan-KO43GTOR.ts.map → agent-plan-P5B5RGZ7.ts.map} +0 -0
  105. /package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts.map → agent-refusal-retry-476YGNU5.ts.map} +0 -0
  106. /package/dist/chunks/{agent-store-UT52DS7B.ts.map → agent-store-RJSMNOYP.ts.map} +0 -0
  107. /package/dist/chunks/{agent-sync-VM3TXWA3.ts.map → agent-sync-EUK2CPSS.ts.map} +0 -0
  108. /package/dist/chunks/{chunk-UCD7TQDT.ts.map → chunk-3IYAPMST.ts.map} +0 -0
  109. /package/dist/chunks/{chunk-DOJRPBQU.ts.map → chunk-5TSCBBPP.ts.map} +0 -0
  110. /package/dist/chunks/{chunk-IMMMZUFC.ts.map → chunk-67INJPKR.ts.map} +0 -0
  111. /package/dist/chunks/{chunk-CDQGSZL3.ts.map → chunk-6LD5KX55.ts.map} +0 -0
  112. /package/dist/chunks/{chunk-MDUORMCK.ts.map → chunk-6O3EDBRL.ts.map} +0 -0
  113. /package/dist/chunks/{chunk-STVQQ5KG.ts.map → chunk-7ZIYU5QS.ts.map} +0 -0
  114. /package/dist/chunks/{chunk-AO6EDUA6.ts.map → chunk-AKMV47YC.ts.map} +0 -0
  115. /package/dist/chunks/{chunk-66GAUVDL.ts.map → chunk-BEST45H2.ts.map} +0 -0
  116. /package/dist/chunks/{chunk-PWA3YN5Z.ts.map → chunk-CPT2BN6N.ts.map} +0 -0
  117. /package/dist/chunks/{chunk-73MDIVYJ.ts.map → chunk-DN4IA7MR.ts.map} +0 -0
  118. /package/dist/chunks/{chunk-4RLATUP4.ts.map → chunk-DRTC4PKM.ts.map} +0 -0
  119. /package/dist/chunks/{chunk-PHANBTVM.ts.map → chunk-ESKOPQVB.ts.map} +0 -0
  120. /package/dist/chunks/{chunk-ODF2M4RF.ts.map → chunk-F7E3VFXU.ts.map} +0 -0
  121. /package/dist/chunks/{chunk-MRIPZGPR.ts.map → chunk-FLHCOJCD.ts.map} +0 -0
  122. /package/dist/chunks/{chunk-5WJQ6XOW.ts.map → chunk-GNYGNHFF.ts.map} +0 -0
  123. /package/dist/chunks/{chunk-DFJGTVPX.ts.map → chunk-KYTT7KV4.ts.map} +0 -0
  124. /package/dist/chunks/{chunk-UGSSZWQ6.ts.map → chunk-MKSMJPVA.ts.map} +0 -0
  125. /package/dist/chunks/{chunk-RAMHGOX2.ts.map → chunk-OJARL2TG.ts.map} +0 -0
  126. /package/dist/chunks/{chunk-U77DWNRH.ts.map → chunk-Q23JJW7E.ts.map} +0 -0
  127. /package/dist/chunks/{chunk-QWV3XKNJ.ts.map → chunk-TIMWN2RH.ts.map} +0 -0
  128. /package/dist/chunks/{chunk-Z5O2TLNF.ts.map → chunk-TZC7GDJE.ts.map} +0 -0
  129. /package/dist/chunks/{chunk-TO2GMX3K.ts.map → chunk-XJHQPB3E.ts.map} +0 -0
  130. /package/dist/chunks/{chunk-JY2RVOUL.ts.map → chunk-Y4UVXOLQ.ts.map} +0 -0
  131. /package/dist/chunks/{chunk-M5IV3KL2.ts.map → chunk-ZDDBZH72.ts.map} +0 -0
  132. /package/dist/chunks/{config-B37W7FXW.ts.map → config-HYG7G5EM.ts.map} +0 -0
  133. /package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts.map → conflict-artifacts-J6WSDCWP.ts.map} +0 -0
  134. /package/dist/chunks/{conflict-review-BQSHSJB3.ts.map → conflict-review-2QMO2K6Q.ts.map} +0 -0
  135. /package/dist/chunks/{continuous-record-4DQ2EAZS.ts.map → continuous-record-AH7JGYKM.ts.map} +0 -0
  136. /package/dist/chunks/{continuous-sync-ZT3UEQLK.ts.map → continuous-sync-VRMXCA5X.ts.map} +0 -0
  137. /package/dist/chunks/{local-fields-B6JWLPAE.ts.map → local-fields-JEKAPZKD.ts.map} +0 -0
  138. /package/dist/chunks/{manager-result-dispatcher-NODX3KKH.ts.map → manager-result-dispatcher-6W52NM7F.ts.map} +0 -0
  139. /package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts.map → merge-baseline-store-65GKUMXI.ts.map} +0 -0
  140. /package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts.map → remote-selection-ui-TD4C4W3J.ts.map} +0 -0
  141. /package/dist/chunks/{setup-switch-66GWMBNH.ts.map → setup-switch-PIPXYPCW.ts.map} +0 -0
  142. /package/dist/chunks/{setup-wizard-OHZXJVQZ.ts.map → setup-wizard-RR5U3SUB.ts.map} +0 -0
  143. /package/dist/chunks/{snapshot-VRHNAUUH.ts.map → snapshot-RGNDYT6A.ts.map} +0 -0
  144. /package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts.map → snapshot-transaction-26BQO36S.ts.map} +0 -0
  145. /package/dist/chunks/{sync-inspection-4WFQEJOE.ts.map → sync-inspection-MOMLGUTD.ts.map} +0 -0
  146. /package/dist/chunks/{sync-operations-X4AOXMBT.ts.map → sync-operations-CV3TULLD.ts.map} +0 -0
  147. /package/dist/chunks/{sync-state-SWLSMK4G.ts.map → sync-state-7L4NT7W2.ts.map} +0 -0
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+
5
+ type MutationGuards = { signal?: AbortSignal; validateMutation?: () => void };
6
+
7
+ function assertCurrent(options: MutationGuards) {
8
+ options.validateMutation?.();
9
+ options.signal?.throwIfAborted();
10
+ }
11
+
12
+ function isMissing(error: unknown) {
13
+ const code = (error as NodeJS.ErrnoException).code;
14
+ return code === "ENOENT" || code === "ENOTDIR";
15
+ }
16
+
17
+ async function targetMissing(target: string, options: MutationGuards) {
18
+ assertCurrent(options);
19
+ try {
20
+ await fs.lstat(target);
21
+ assertCurrent(options);
22
+ return false;
23
+ } catch (error) {
24
+ assertCurrent(options);
25
+ if (!isMissing(error)) throw error;
26
+ return true;
27
+ }
28
+ }
29
+
30
+ async function queueKey(target: string, options: MutationGuards) {
31
+ assertCurrent(options);
32
+ try {
33
+ const key = await fs.realpath(target);
34
+ assertCurrent(options);
35
+ return key;
36
+ } catch (error) {
37
+ assertCurrent(options);
38
+ if (!isMissing(error)) throw error;
39
+ return path.resolve(target);
40
+ }
41
+ }
42
+
43
+ /** Resolve missing descendants too, including descendants of a file scheduled for removal. */
44
+ async function assertCanonicalParent(target: string, options: MutationGuards) {
45
+ const parent = path.dirname(path.resolve(target));
46
+ const suffix: string[] = [];
47
+ for (let current = parent; ; current = path.dirname(current)) {
48
+ assertCurrent(options);
49
+ let canonical: string;
50
+ try {
51
+ canonical = await fs.realpath(current);
52
+ } catch (error) {
53
+ assertCurrent(options);
54
+ if (!isMissing(error) || path.dirname(current) === current) throw error;
55
+ suffix.unshift(path.basename(current));
56
+ continue;
57
+ }
58
+ assertCurrent(options);
59
+ // Pi queues existing paths by realpath and missing paths by lexical resolve.
60
+ // Its public API cannot reserve a missing alias key while that alias exists,
61
+ // or report a key that changes during registration. Refuse aliased parents
62
+ // before mutation rather than risk a non-reentrant queue or overwrite a writer.
63
+ if (path.join(canonical, ...suffix) !== parent)
64
+ throw new Error(
65
+ "Snapshot mutation through an aliased parent is unsafe; use the canonical agent/session path. Transaction evidence retained for review.",
66
+ );
67
+ return;
68
+ }
69
+ }
70
+
71
+ export async function assertCanonicalMutationTargets(targets: readonly string[], options: MutationGuards) {
72
+ for (const target of targets) await assertCanonicalParent(target, options);
73
+ }
74
+
75
+ export async function withTargetQueues<T>(
76
+ targets: readonly string[],
77
+ options: MutationGuards,
78
+ action: (heldKeys: ReadonlySet<string>) => Promise<T>,
79
+ ): Promise<T> {
80
+ // Check the whole group before reserving any key or changing live files.
81
+ await assertCanonicalMutationTargets(targets, options);
82
+ const keys = new Set<string>();
83
+ const missing = new Set<string>();
84
+ for (const target of targets) {
85
+ if (await targetMissing(target, options)) missing.add(target);
86
+ const key = await queueKey(target, options);
87
+ if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
88
+ keys.add(key);
89
+ }
90
+ const ordered = [...keys].sort();
91
+ async function acquire(index: number): Promise<T> {
92
+ assertCurrent(options);
93
+ const key = ordered[index];
94
+ // Pass the resolved key, not an alias whose lookup can select a different key.
95
+ if (key) return withFileMutationQueue(key, () => acquire(index + 1));
96
+ for (const target of targets) {
97
+ await assertCanonicalParent(target, options);
98
+ if (missing.has(target) && !(await targetMissing(target, options)))
99
+ throw new Error("Missing transaction target appeared during queue acquisition; review is required.");
100
+ if (!keys.has(await queueKey(target, options)))
101
+ throw new Error("Transaction queue identity changed; review is required.");
102
+ }
103
+ assertCurrent(options);
104
+ return action(keys);
105
+ }
106
+ return acquire(0);
107
+ }
108
+
109
+ export async function withMissingTargetQueues<T>(
110
+ targets: readonly string[],
111
+ heldKeys: ReadonlySet<string>,
112
+ options: MutationGuards,
113
+ action: (heldKeys: ReadonlySet<string>) => Promise<T>,
114
+ ): Promise<T> {
115
+ const unique = [...new Set(targets)];
116
+ const requireAbsent = async () => {
117
+ for (const target of unique) {
118
+ await assertCanonicalParent(target, options);
119
+ if (!(await targetMissing(target, options)))
120
+ throw new Error("Snapshot target changed before destination queue reservation; evidence retained.");
121
+ }
122
+ };
123
+ // Canonical parents make each missing key equal to the intended installed key.
124
+ // Retain the old physical key and exclude it from these non-reentrant queues.
125
+ const keys = unique
126
+ .map((target) => path.resolve(target))
127
+ .filter((key) => !heldKeys.has(key))
128
+ .sort();
129
+ await requireAbsent();
130
+ const combined = new Set([...heldKeys, ...keys]);
131
+ async function acquire(index: number): Promise<T> {
132
+ assertCurrent(options);
133
+ const key = keys[index];
134
+ if (key) return withFileMutationQueue(key, () => acquire(index + 1));
135
+ // A writer registered in the removal gap must finish before this check.
136
+ // Even recognizable pre/postimages belong to that writer, not this transaction.
137
+ await requireAbsent();
138
+ assertCurrent(options);
139
+ return action(combined);
140
+ }
141
+ return acquire(0);
142
+ }
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
5
  import { assertWithinRoot, isDeniedPath, isPathInside } from "../paths.js";
7
6
  import { syncDirectory, writeJson } from "../state/json-file.js";
8
7
  import { withLock } from "../state/lock.js";
@@ -11,6 +10,7 @@ import { stateDir } from "../state/state-directory.js";
11
10
  import { mergePathIdentity } from "../sync/file-merge-planner.js";
12
11
  import { agentDir, configuredSessionDir } from "./session-paths.js";
13
12
  import { caseReplacementSpelling, coalesceCaseReplacements, isCaseReplacement } from "./snapshot-case-replacement.js";
13
+ import { withMissingTargetQueues, withTargetQueues } from "./snapshot-mutation-queues.js";
14
14
  import { sessionStorageRoot } from "./snapshot-paths.js";
15
15
  import {
16
16
  prepareSessionRootTransition,
@@ -69,7 +69,7 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
69
69
  const targets = [...new Set([...coalesced.plan.deletes, ...coalesced.plan.writes.map((item) => item.target)])].sort();
70
70
  if (targets.length > 16_384)
71
71
  throw new Error("Snapshot transaction exceeds its target bound; review a smaller transfer.");
72
- return withTargetQueues(targets, async () => {
72
+ return withTargetQueues(targets, options, async (heldKeys) => {
73
73
  options.signal?.throwIfAborted();
74
74
  if (
75
75
  targets.some((target) =>
@@ -134,6 +134,10 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
134
134
  ...(entry.afterTarget ? [[entry.afterTarget, entry] as const] : []),
135
135
  ]),
136
136
  );
137
+ // A released additional key cannot authorize rollback, even for recognized raced bytes.
138
+ const changesQueueKey = transaction.journal.entries.some(
139
+ (entry) => !heldKeys.has(entry.target) || (entry.afterTarget && !heldKeys.has(entry.afterTarget)),
140
+ );
137
141
  try {
138
142
  for (const target of transaction.deletes.sort((a, b) => a.length - b.length)) {
139
143
  options.signal?.throwIfAborted();
@@ -142,75 +146,81 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
142
146
  await removeOwnedTarget(transaction.directory, entry, transaction.journal, options);
143
147
  await syncDirectory(path.dirname(target));
144
148
  }
145
- await withCaseDestinationQueues([...coalesced.replacements], options, async () => {
146
- await retireRemovalIntent(
147
- transaction.directory,
148
- transaction.journal,
149
- transaction.journal.entries.filter(
150
- (entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0),
151
- ),
152
- options,
153
- );
154
- // Only this uninterrupted call knows which deleted writes have not attempted installation yet.
155
- const uninstalledWrites = new Set(transaction.deletedWrites);
156
- for (const item of plan.writes) {
157
- options.signal?.throwIfAborted();
158
- const entry = entriesByTarget.get(item.target);
159
- if (!entry) throw new Error("Unowned transaction target.");
160
- const deletedByThisCall = uninstalledWrites.delete(entry.target);
161
- await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
162
- options.validateMutation?.();
163
- options.signal?.throwIfAborted();
164
- await fs.mkdir(path.dirname(item.target), { recursive: true });
165
- options.validateMutation?.();
166
- options.signal?.throwIfAborted();
167
- const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
168
- try {
169
- let mode = 0o600;
170
- try {
171
- const stat = await fs.lstat(item.target);
172
- if (stat.isFile()) mode = stat.mode & 0o777;
173
- } catch (error) {
174
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
175
- }
176
- const handle = await fs.open(temp, "wx", mode);
177
- try {
178
- await handle.writeFile(item.content);
179
- await handle.sync();
180
- } finally {
181
- await handle.close();
182
- }
149
+ await withMissingTargetQueues(
150
+ transaction.journal.entries
151
+ .filter((entry) => entry.removalPending)
152
+ .flatMap((entry) => [entry.target, ...(entry.afterTarget ? [entry.afterTarget] : [])]),
153
+ heldKeys,
154
+ options,
155
+ async () => {
156
+ await retireRemovalIntent(
157
+ transaction.directory,
158
+ transaction.journal,
159
+ transaction.journal.entries.filter(
160
+ (entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0),
161
+ ),
162
+ options,
163
+ );
164
+ // Only this uninterrupted call knows which deleted writes have not attempted installation yet.
165
+ const uninstalledWrites = new Set(transaction.deletedWrites);
166
+ for (const item of plan.writes) {
167
+ options.signal?.throwIfAborted();
168
+ const entry = entriesByTarget.get(item.target);
169
+ if (!entry) throw new Error("Unowned transaction target.");
170
+ const deletedByThisCall = uninstalledWrites.delete(entry.target);
183
171
  await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
172
+ options.validateMutation?.();
184
173
  options.signal?.throwIfAborted();
174
+ await fs.mkdir(path.dirname(item.target), { recursive: true });
185
175
  options.validateMutation?.();
186
- await fs.rename(temp, item.target);
187
- await syncDirectory(path.dirname(item.target));
188
- } finally {
189
- await fs.rm(temp, { force: true });
176
+ options.signal?.throwIfAborted();
177
+ const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
178
+ try {
179
+ let mode = 0o600;
180
+ try {
181
+ const stat = await fs.lstat(item.target);
182
+ if (stat.isFile()) mode = stat.mode & 0o777;
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
185
+ }
186
+ const handle = await fs.open(temp, "wx", mode);
187
+ try {
188
+ await handle.writeFile(item.content);
189
+ await handle.sync();
190
+ } finally {
191
+ await handle.close();
192
+ }
193
+ await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
194
+ options.signal?.throwIfAborted();
195
+ options.validateMutation?.();
196
+ await fs.rename(temp, item.target);
197
+ await syncDirectory(path.dirname(item.target));
198
+ } finally {
199
+ await fs.rm(temp, { force: true });
200
+ }
190
201
  }
191
- }
192
- options.validateMutation?.();
193
- options.signal?.throwIfAborted();
194
- await syncMutationParents(
195
- targets,
196
- [transaction.journal.root, ...(transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : [])],
197
- options,
198
- );
199
- options.validateMutation?.();
200
- options.signal?.throwIfAborted();
201
- });
202
+ options.validateMutation?.();
203
+ options.signal?.throwIfAborted();
204
+ await syncMutationParents(
205
+ targets,
206
+ [transaction.journal.root, ...(transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : [])],
207
+ options,
208
+ );
209
+ options.validateMutation?.();
210
+ options.signal?.throwIfAborted();
211
+ },
212
+ );
202
213
  } catch (error) {
203
214
  // Aborted owners cannot roll back files after another session has replaced them.
204
215
  if (options.signal?.aborted)
205
216
  throw new Error("Snapshot apply cancelled; guarded transaction evidence retained for review.");
206
- // Additional spelling queues have been released: do not start a case rollback
207
- // under only the original key. Startup recovery reserves its own destination key.
208
- if (coalesced.replacements.size)
209
- throw new Error("Case replacement apply failed; guarded transaction evidence retained for review.", {
217
+ // Additional queues have been released. Recovery must reserve missing keys afresh.
218
+ if (changesQueueKey)
219
+ throw new Error("Snapshot apply failed; guarded transaction evidence retained for review.", {
210
220
  cause: error,
211
221
  });
212
222
  try {
213
- await restoreTransaction(transaction.directory, transaction.journal, options);
223
+ await restoreTransaction(transaction.directory, transaction.journal, options, heldKeys);
214
224
  } catch (recoveryError) {
215
225
  throw new AggregateError(
216
226
  [error, recoveryError],
@@ -285,8 +295,8 @@ export async function recoverPendingSnapshotTransactions(options: TransactionOpt
285
295
  await removeTransaction(directory);
286
296
  continue;
287
297
  }
288
- await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), () =>
289
- restoreTransaction(directory, journal, { ...options, sessionDir }),
298
+ await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), options, (heldKeys) =>
299
+ restoreTransaction(directory, journal, { ...options, sessionDir }, heldKeys),
290
300
  );
291
301
  }
292
302
  }
@@ -388,7 +398,36 @@ async function prepareTransaction(
388
398
  return { directory, journal, deletes: indexed.deletes, deletedWrites: indexed.deletedWrites };
389
399
  }
390
400
 
391
- async function restoreTransaction(directory: string, journal: TransactionJournal, options: TransactionOptions) {
401
+ async function restoreTransaction(
402
+ directory: string,
403
+ journal: TransactionJournal,
404
+ options: TransactionOptions,
405
+ heldKeys: ReadonlySet<string>,
406
+ ) {
407
+ validateJournal(directory, journal, options.sessionDir);
408
+ const missingSpellings: string[] = [];
409
+ for (const entry of journal.entries) {
410
+ if (!entry.afterTarget) continue;
411
+ options.validateMutation?.();
412
+ options.signal?.throwIfAborted();
413
+ const spelling = await caseReplacementSpelling(entry.target, entry.afterTarget);
414
+ options.validateMutation?.();
415
+ options.signal?.throwIfAborted();
416
+ if (spelling === undefined) missingSpellings.push(entry.target, entry.afterTarget);
417
+ }
418
+ // Reserve an interrupted removal's other spelling before staging or image checks:
419
+ // recognizable bytes installed by a writer in that gap are not our postimage.
420
+ return withMissingTargetQueues(missingSpellings, heldKeys, options, (reservedKeys) =>
421
+ restoreQueuedTransaction(directory, journal, options, reservedKeys),
422
+ );
423
+ }
424
+
425
+ async function restoreQueuedTransaction(
426
+ directory: string,
427
+ journal: TransactionJournal,
428
+ options: TransactionOptions,
429
+ heldKeys: ReadonlySet<string>,
430
+ ) {
392
431
  validateJournal(directory, journal, options.sessionDir);
393
432
  options.validateMutation?.();
394
433
  options.signal?.throwIfAborted();
@@ -453,8 +492,13 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
453
492
  !(await removeOwnedTarget(directory, entry, journal, options, before))
454
493
  )
455
494
  continue;
456
- await withCaseDestinationQueues(
457
- needsCaseQueue && entry.afterTarget ? [[entry.afterTarget, entry.target]] : [],
495
+ await withMissingTargetQueues(
496
+ entry.afterTarget && (needsCaseQueue || verified === "missing")
497
+ ? [entry.target, entry.afterTarget]
498
+ : verified.startsWith("directory:")
499
+ ? [entry.target]
500
+ : [],
501
+ heldKeys,
458
502
  options,
459
503
  async () => {
460
504
  const atRename = await verifyTarget(directory, entry, journal);
@@ -501,17 +545,19 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
501
545
  options.validateMutation?.();
502
546
  options.signal?.throwIfAborted();
503
547
  if (!(await removeOwnedTarget(directory, entry, journal, options, before))) continue;
504
- const atRename = await verifyTarget(directory, entry, journal);
505
- options.validateMutation?.();
506
- options.signal?.throwIfAborted();
507
- if (atRename === before) continue;
508
- await retireRestorationIntent(directory, journal, entry, options);
509
- const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
510
- options.validateMutation?.();
511
- options.signal?.throwIfAborted();
512
- if (atInstall === before) continue;
513
- await fs.rename(temporary, entry.target);
514
- await syncDirectory(path.dirname(entry.target));
548
+ await withMissingTargetQueues([entry.target], heldKeys, options, async () => {
549
+ const atRename = await verifyTarget(directory, entry, journal);
550
+ options.validateMutation?.();
551
+ options.signal?.throwIfAborted();
552
+ if (atRename === before) return;
553
+ await retireRestorationIntent(directory, journal, entry, options);
554
+ const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
555
+ options.validateMutation?.();
556
+ options.signal?.throwIfAborted();
557
+ if (atInstall === before) return;
558
+ await fs.rename(temporary, entry.target);
559
+ await syncDirectory(path.dirname(entry.target));
560
+ });
515
561
  } finally {
516
562
  await fs.rm(temporary, { recursive: true, force: true });
517
563
  }
@@ -867,55 +913,6 @@ async function assertSafeParents(root: string, sessionRoot: string | undefined,
867
913
  }
868
914
  }
869
915
  }
870
- async function withTargetQueues<T>(targets: string[], action: () => Promise<T>) {
871
- const keys = new Set<string>();
872
- for (const target of targets) {
873
- let key = path.resolve(target);
874
- try {
875
- key = await fs.realpath(target);
876
- } catch (error) {
877
- const code = (error as NodeJS.ErrnoException).code;
878
- if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
879
- }
880
- if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
881
- keys.add(key);
882
- }
883
- async function acquire(index: number): Promise<T> {
884
- const target = targets[index];
885
- return target ? withFileMutationQueue(target, () => acquire(index + 1)) : action();
886
- }
887
- return acquire(0);
888
- }
889
- async function withCaseDestinationQueues<T>(
890
- spellings: readonly (readonly [string, string])[],
891
- options: TransactionOptions,
892
- action: () => Promise<T>,
893
- ): Promise<T> {
894
- if (!spellings.length) return action();
895
- const requireAbsent = async () => {
896
- for (const [before, after] of spellings) {
897
- options.validateMutation?.();
898
- options.signal?.throwIfAborted();
899
- const spelling = await caseReplacementSpelling(before, after);
900
- options.validateMutation?.();
901
- options.signal?.throwIfAborted();
902
- if (spelling !== undefined)
903
- throw new Error("Case replacement changed before destination queue reservation; evidence retained.");
904
- }
905
- };
906
- // Existing aliases share one non-reentrant realpath queue. Only after removal can
907
- // we reserve the distinct missing spelling while retaining the original queue.
908
- // Pi writers that registered in the gap finish before this callback; refuse their
909
- // bytes, including recognizable pre/postimages, rather than treating them as ours.
910
- await requireAbsent();
911
- return withTargetQueues(
912
- spellings.map(([, after]) => after),
913
- async () => {
914
- await requireAbsent();
915
- return action();
916
- },
917
- );
918
- }
919
916
  async function completeTransaction(directory: string, journal: TransactionJournal) {
920
917
  // Publish completion before deleting any backup. A surviving journal after power loss
921
918
  // may only retry cleanup, never roll back already accepted files.
@@ -5,6 +5,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { agentDir } from "../snapshot/session-paths.js";
6
6
  import { createSnapshot, snapshotTarget } from "../snapshot/snapshot.js";
7
7
  import { preflightSnapshotApply, preflightSnapshotMutations } from "../snapshot/snapshot-apply.js";
8
+ import { assertCanonicalMutationTargets } from "../snapshot/snapshot-mutation-queues.js";
8
9
  import { sessionStorageRoot } from "../snapshot/snapshot-paths.js";
9
10
  import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
10
11
  import { syncDirectory } from "../state/json-file.js";
@@ -34,6 +35,10 @@ export async function preflightMergedTargets(
34
35
  for (const relative of paths) {
35
36
  if (changed.has(relative)) await assertFilesystemTarget(root, relative, options, removedTargets, beforeHashes);
36
37
  }
38
+ await assertCanonicalMutationTargets(
39
+ [...changed].map((relative) => snapshotTarget(root, relative, options.sessionDir)),
40
+ options,
41
+ );
37
42
  }
38
43
 
39
44
  function replacementPaths(removed: Set<string>, afterHashes: Readonly<Record<string, string>>) {
@@ -244,6 +249,7 @@ export async function applyMergedSnapshot(
244
249
  protectedTarget,
245
250
  new Set(Object.keys(afterHashes)),
246
251
  );
252
+ await assertCanonicalMutationTargets(targets, options);
247
253
  // Existing case aliases share Pi's non-reentrant realpath queue. Reserve the
248
254
  // missing destination separately only after deleting its reviewed preimage.
249
255
  const targetsByQueue = new Map<string, string[]>();
@@ -28,6 +28,7 @@ interface CancellableOperationOptions {
28
28
  cancelledMessage?: string | null;
29
29
  target?: string;
30
30
  signal?: AbortSignal;
31
+ isCurrent?: () => boolean;
31
32
  }
32
33
 
33
34
  export async function runCancellableOperation(
@@ -37,21 +38,45 @@ export async function runCancellableOperation(
37
38
  runRoute: RunRoute,
38
39
  options: CancellableOperationOptions = {},
39
40
  ): Promise<CancellableOperationResult> {
41
+ const result = await runCancellableTask(
42
+ ctx,
43
+ message,
44
+ (signal, onCommit, onDialog) => runRoute(route, signal, onCommit, options.target, onDialog),
45
+ options,
46
+ );
47
+ return result.kind === "completed" ? (result.value ?? { kind: "failed" }) : result;
48
+ }
49
+
50
+ export type CancellableTaskResult<Value> =
51
+ | { kind: "completed"; value: Value }
52
+ | { kind: "closed" }
53
+ | { kind: "cancelled" };
54
+
55
+ /** Keep non-route work under the same cancellation and drain owner as transfers. */
56
+ export async function runCancellableTask<Value>(
57
+ ctx: ExtensionContext,
58
+ message: string,
59
+ task: (
60
+ signal: AbortSignal | undefined,
61
+ onCommit?: () => void,
62
+ onDialog?: (active: boolean) => void,
63
+ ) => Promise<Value>,
64
+ options: Omit<CancellableOperationOptions, "target"> = {},
65
+ ): Promise<CancellableTaskResult<Value>> {
40
66
  const {
41
67
  commitAware = false,
42
68
  cancelAcrossDialogs = false,
43
69
  cancelledMessage = "Check cancelled; no settings or files were changed.",
44
- target,
45
70
  signal,
71
+ isCurrent = () => true,
46
72
  } = options;
47
- if (ctx.mode !== "tui") {
48
- return (await runRoute(route, signal, undefined, target)) ?? { kind: "failed" };
49
- }
73
+ if (signal?.aborted || !isCurrent()) return { kind: "closed" };
74
+ if (ctx.mode !== "tui") return { kind: "completed", value: await task(signal) };
50
75
  let commitStarted = false;
51
- let routeResult: RunRouteResult | undefined;
76
+ let taskResult!: Value;
52
77
  const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }, ExtensionContext>(ctx, {
53
78
  signal,
54
- isCurrent: () => !signal?.aborted,
79
+ isCurrent: () => !signal?.aborted && isCurrent(),
55
80
  create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
56
81
  const loader = new BorderedLoader(tui, theme, message, { cancellable: false });
57
82
  const cancelHint = formatInteractionHints(keybindings, [
@@ -80,13 +105,11 @@ export async function runCancellableOperation(
80
105
  return cancel(data) ? { consume: true } : undefined;
81
106
  })
82
107
  : undefined;
83
- let pendingRoute: ReturnType<RunRoute>;
108
+ let pendingTask: Promise<Value>;
84
109
  try {
85
- pendingRoute = runRoute(
86
- route,
110
+ pendingTask = task(
87
111
  interactionSignal,
88
112
  commitAware ? () => (commitStarted = true) : undefined,
89
- target,
90
113
  cancelAcrossDialogs
91
114
  ? (active) => {
92
115
  activeDialogs += active ? 1 : -1;
@@ -98,9 +121,9 @@ export async function runCancellableOperation(
98
121
  loader.dispose();
99
122
  throw error;
100
123
  }
101
- const operation = pendingRoute.then(
124
+ const operation = pendingTask.then(
102
125
  (result) => {
103
- routeResult = result;
126
+ taskResult = result;
104
127
  complete({});
105
128
  },
106
129
  (error: unknown) => complete({ error }),
@@ -134,7 +157,7 @@ export async function runCancellableOperation(
134
157
  return { kind: "cancelled" };
135
158
  }
136
159
  if (interaction.value.error) throw interaction.value.error;
137
- return routeResult ?? { kind: "failed" };
160
+ return { kind: "completed", value: taskResult };
138
161
  }
139
162
 
140
163
  function hasControlCharacter(value: string) {
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { loadConfig } from "../settings/config.js";
2
+ import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
3
3
  import { activeLocalConfigPath } from "../settings/config-file.js";
4
4
  import { readLocalConfigObject } from "../settings/settings-store.js";
5
5
  import type { AnySyncConfig } from "../settings/settings-types.js";
@@ -39,6 +39,7 @@ export const MAIN_MENU_ACTIONS = [
39
39
  export interface ManagerDescription {
40
40
  title: string;
41
41
  actions: string[];
42
+ configReview?: { setupName: string; fingerprint: string };
42
43
  operation?: OperationAvailability;
43
44
  attention?: SyncAttentionState;
44
45
  observation?: StartupObservation;
@@ -176,6 +177,7 @@ export async function describeManagerState(
176
177
  return {
177
178
  title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"),
178
179
  actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
180
+ configReview: { setupName: config.setupName, fingerprint: syncCheckConfigFingerprint(config) },
179
181
  operation,
180
182
  ...(currentObservation ? { observation: currentObservation } : {}),
181
183
  ...(currentAttention