@narumitw/pi-sync 0.54.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +40 -15
  2. package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
  3. package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
  4. package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
  5. package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
  6. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
  7. package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
  8. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
  9. package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
  10. package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
  11. package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
  12. package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
  13. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-AAEEHR7H.ts} +31 -87
  14. package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
  15. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-B4QZBDBT.ts} +2 -2
  16. package/dist/chunks/{chunk-CL7BZRRQ.ts → chunk-CRKTAPSL.ts} +8 -8
  17. package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
  18. package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
  19. package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
  20. package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
  21. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-EUMINX7L.ts} +6 -6
  22. package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
  23. package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
  24. package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
  25. package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
  26. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-GP5DU62N.ts} +8 -9
  27. package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
  28. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-GSNPASKB.ts} +2 -2
  29. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-IWZH5KYS.ts} +23 -7
  30. package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
  31. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-JJTARUSO.ts} +15 -14
  32. package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
  33. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-NLWS55B6.ts} +15 -2
  34. package/dist/chunks/chunk-NLWS55B6.ts.map +7 -0
  35. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-OEWQVWLC.ts} +4 -4
  36. package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-OTZRCKYP.ts} +380 -510
  37. package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
  38. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-OWBAZA6J.ts} +24 -12
  39. package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
  40. package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
  41. package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
  42. package/dist/chunks/chunk-QGK445K7.ts +21 -0
  43. package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
  44. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-QO333NM4.ts} +49 -21
  45. package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
  46. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-QSHMOL3I.ts} +22 -24
  47. package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
  48. package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
  49. package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
  50. package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
  51. package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
  52. package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
  53. package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
  54. package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
  55. package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
  56. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-UPNVIUBN.ts} +3 -3
  57. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  58. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  59. package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
  60. package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
  61. package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
  62. package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
  63. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-YK52VDXG.ts} +6 -5
  64. package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
  65. package/dist/chunks/config-KGIYTTXQ.ts +37 -0
  66. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
  67. package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-6WIEQLXW.ts} +31 -24
  68. package/dist/chunks/{conflict-review-4PLVLMRO.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
  69. package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
  70. package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
  71. package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
  72. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-3JJYOVNR.ts} +5 -5
  73. package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
  74. package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
  75. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
  76. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-KRAW3NE2.ts} +464 -164
  77. package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
  78. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-RFCAH7KL.ts} +7 -5
  79. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-5725JDNC.ts} +9 -8
  80. package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
  81. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y7WSCLJI.ts} +38 -38
  82. package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
  83. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-E7PHKI6J.ts} +7 -6
  84. package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
  85. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-65DROCIJ.ts} +14 -13
  86. package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
  87. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-XFJOAMJD.ts} +9 -8
  88. package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
  89. package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-HDUDT5PD.ts} +188 -73
  90. package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
  91. package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
  92. package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
  93. package/dist/index.ts +246 -131
  94. package/dist/index.ts.map +3 -3
  95. package/docs/agent-mode-verification.md +99 -0
  96. package/docs/continuous-sync-verification.md +87 -0
  97. package/docs/settings.md +131 -11
  98. package/docs/tui-contract.md +61 -0
  99. package/package.json +3 -2
  100. package/src/backends/git/git-backend.ts +1 -4
  101. package/src/backends/git/git-runner.ts +1 -5
  102. package/src/backends/s3/s3-backend.ts +3 -25
  103. package/src/backends/s3/s3-client.ts +14 -2
  104. package/src/backends/snapshot-pointer.ts +27 -0
  105. package/src/backends/webdav/webdav-backend.ts +3 -29
  106. package/src/backends/webdav/webdav-client.ts +11 -7
  107. package/src/commands/command-execution.ts +7 -3
  108. package/src/commands/command-handler.ts +15 -3
  109. package/src/commands/command-types.ts +10 -0
  110. package/src/commands/command.ts +4 -1
  111. package/src/settings/config.ts +20 -10
  112. package/src/settings/settings-store.ts +1 -34
  113. package/src/settings/settings-types.ts +10 -0
  114. package/src/settings/settings-validation.ts +4 -1
  115. package/src/snapshot/snapshot-codec.ts +1 -5
  116. package/src/state/lock.ts +13 -10
  117. package/src/state/merge-baseline-store.ts +19 -1
  118. package/src/state/state-directory.ts +8 -1
  119. package/src/sync/agent-candidate.ts +251 -0
  120. package/src/sync/agent-plan.ts +87 -0
  121. package/src/sync/agent-policy.ts +13 -0
  122. package/src/sync/agent-request.ts +17 -0
  123. package/src/sync/agent-runner.ts +188 -0
  124. package/src/sync/agent-store.ts +151 -0
  125. package/src/sync/agent-sync.ts +91 -0
  126. package/src/sync/apply-review.ts +63 -0
  127. package/src/sync/automatic-sync.ts +46 -33
  128. package/src/sync/continuous-errors.ts +85 -0
  129. package/src/sync/continuous-lease.ts +221 -0
  130. package/src/sync/continuous-record.ts +179 -0
  131. package/src/sync/continuous-storage.ts +55 -0
  132. package/src/sync/continuous-sync.ts +516 -0
  133. package/src/sync/continuous-watch.ts +59 -0
  134. package/src/sync/merged-sync.ts +268 -112
  135. package/src/sync/remote-snapshot.ts +2 -3
  136. package/src/sync/settings-merge.ts +19 -3
  137. package/src/sync/setup-switch.ts +1 -5
  138. package/src/sync/startup-check.ts +51 -29
  139. package/src/sync/startup-timings.ts +90 -0
  140. package/src/sync/sync-inspection.ts +16 -5
  141. package/src/sync/sync-mutations.ts +55 -28
  142. package/src/sync/sync-queries.ts +19 -3
  143. package/src/sync/text-merge.ts +3 -3
  144. package/src/sync-extension.ts +75 -19
  145. package/src/ui/agent-mode-setting.ts +91 -0
  146. package/src/ui/manager-state.ts +12 -6
  147. package/src/ui/manager-ui.ts +57 -7
  148. package/src/ui/merge-review.ts +3 -3
  149. package/src/ui/merged-sync-summary.ts +2 -18
  150. package/src/ui/remote-selection-ui.ts +5 -5
  151. package/src/ui/settings-frame.ts +55 -0
  152. package/src/ui/settings-ui.ts +223 -43
  153. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  154. package/src/ui/setup/setup-name-ui.ts +2 -5
  155. package/src/ui/setup/setup-prompts.ts +4 -3
  156. package/src/ui/setup/setup-switcher.ts +2 -0
  157. package/src/ui/sync-behavior.ts +15 -0
  158. package/src/ui/sync-format.ts +69 -38
  159. package/src/ui/sync-setups-ui.ts +3 -5
  160. package/src/ui/transfer-changes.ts +33 -0
  161. package/src/ui/transfer-review.ts +27 -0
  162. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  163. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  164. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  165. package/dist/chunks/chunk-5ZWQ33XL.ts.map +0 -7
  166. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  167. package/dist/chunks/chunk-IJ6VV6FN.ts.map +0 -7
  168. package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
  169. package/dist/chunks/chunk-ORQRHEEO.ts +0 -362
  170. package/dist/chunks/chunk-ORQRHEEO.ts.map +0 -7
  171. package/dist/chunks/chunk-QE7NZZWT.ts.map +0 -7
  172. package/dist/chunks/chunk-UQ3ALBIB.ts.map +0 -7
  173. package/dist/chunks/chunk-V6VZ5TLG.ts.map +0 -7
  174. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  175. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  176. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  177. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  178. package/dist/chunks/s3-backend-AQGV6LFT.ts.map +0 -7
  179. package/dist/chunks/setup-wizard-GSDPDFC4.ts.map +0 -7
  180. package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
  181. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  182. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  183. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  184. /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  185. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  186. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  187. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  188. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  189. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  190. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  191. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  192. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  193. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  194. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import lockfile from "proper-lockfile";
5
+ import { writeJson } from "../state/json-file.js";
6
+ import { withLock } from "../state/lock.js";
7
+ import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "../state/lock-policy.js";
8
+ import { LOCKFILE_FS_ADAPTER } from "../state/lockfile-fs.js";
9
+ import { stateDir } from "../state/state-directory.js";
10
+ import { AGENT_OUTPUT_BYTES } from "./agent-policy.js";
11
+ import { ContinuousError } from "./continuous-errors.js";
12
+
13
+ interface Claim {
14
+ version: 1;
15
+ id: string;
16
+ pid: number;
17
+ }
18
+ function paths() {
19
+ const root = path.join(stateDir(), "agent-resolver");
20
+ return { root, claim: path.join(root, "claim.json") };
21
+ }
22
+ async function directory() {
23
+ const { root } = paths();
24
+ await fs.mkdir(root, { mode: 0o700 });
25
+ }
26
+ async function checkDirectory() {
27
+ const stat = await fs.lstat(paths().root);
28
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32" && stat.mode & 0o077))
29
+ throw new Error("Unsafe agent resolver directory; evidence retained.");
30
+ }
31
+ async function readPrivate(file: string): Promise<unknown> {
32
+ try {
33
+ await checkDirectory();
34
+ const stat = await fs.lstat(file);
35
+ if (
36
+ !stat.isFile() ||
37
+ stat.isSymbolicLink() ||
38
+ stat.nlink !== 1 ||
39
+ stat.size > AGENT_OUTPUT_BYTES * 2 ||
40
+ (process.platform !== "win32" && stat.mode & 0o077)
41
+ )
42
+ throw new Error("Unsafe agent resolver record; evidence retained.");
43
+ return JSON.parse(await fs.readFile(file, "utf8"));
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
46
+ throw new Error("Unreadable agent resolver record; evidence retained.");
47
+ }
48
+ }
49
+ async function readClaim(): Promise<Claim | undefined> {
50
+ const value = await readPrivate(paths().claim);
51
+ if (value === undefined) return;
52
+ const claim = value as Claim;
53
+ if (
54
+ claim?.version !== 1 ||
55
+ typeof claim.id !== "string" ||
56
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(claim.id) ||
57
+ Object.keys(claim).sort().join(",") !== "id,pid,version" ||
58
+ !Number.isSafeInteger(claim.pid) ||
59
+ claim.pid < 1 ||
60
+ claim.pid > 2_147_483_647
61
+ )
62
+ throw new Error("Invalid agent resolver claim; evidence retained.");
63
+ return claim;
64
+ }
65
+ function alive(pid: number) {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
71
+ }
72
+ }
73
+
74
+ /** Caller retains the state-directory migration guard; claim changes use a separate short-lived guard. */
75
+ export async function withAgentClaim<T>(fn: (assertOwner: () => Promise<void>) => Promise<T>): Promise<T> {
76
+ const claim: Claim = { version: 1, id: randomUUID(), pid: process.pid };
77
+ let armed = false;
78
+ const assertOwner = async () => {
79
+ const current = await readClaim();
80
+ if (current?.id !== claim.id || current.pid !== process.pid)
81
+ throw new Error("Agent resolver ownership changed; candidate not applied.");
82
+ };
83
+ try {
84
+ await withLock("agent-claim", async () => {
85
+ try {
86
+ await directory();
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
89
+ }
90
+ await checkDirectory();
91
+ await withClaimGuard(async () => {
92
+ const existing = await readClaim();
93
+ if (existing && alive(existing.pid))
94
+ throw new ContinuousError("contention", "Agent mode deferred: another local resolver is running.");
95
+ // A durability failure can occur after rename: cleanup must own partial initialization too.
96
+ armed = true;
97
+ await writeJson(paths().claim, claim);
98
+ });
99
+ });
100
+ return await fn(assertOwner);
101
+ } finally {
102
+ if (armed) await releaseClaim(claim);
103
+ }
104
+ }
105
+ async function releaseClaim(claim: Claim) {
106
+ // Ordinary transfers may hold the operation lock for an arbitrary time.
107
+ // Cleanup needs only the claim guard; it never waits for that operation lock.
108
+ await withClaimGuard(async () => {
109
+ const current = await readClaim();
110
+ if (current?.id === claim.id && current.pid === claim.pid) await fs.rm(paths().claim);
111
+ });
112
+ }
113
+ async function withClaimGuard<T>(run: () => Promise<T>): Promise<T> {
114
+ await checkDirectory();
115
+ let compromisedError: Error | undefined;
116
+ const release = await lockfile.lock(paths().claim, {
117
+ fs: LOCKFILE_FS_ADAPTER,
118
+ realpath: false,
119
+ stale: LOCK_GUARD_STALE_MS,
120
+ update: LOCK_GUARD_UPDATE_MS,
121
+ retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
122
+ onCompromised: (error) => {
123
+ compromisedError = error;
124
+ },
125
+ });
126
+ try {
127
+ const result = await run();
128
+ if (compromisedError) throw compromisedError;
129
+ return result;
130
+ } finally {
131
+ await release();
132
+ }
133
+ }
134
+ function candidatePath(key: string) {
135
+ if (!/^[a-f0-9]{64}$/u.test(key)) throw new Error("Invalid agent candidate key.");
136
+ return path.join(paths().root, `${key}.json`);
137
+ }
138
+ export async function readAgentCandidate(key: string): Promise<unknown> {
139
+ return readPrivate(candidatePath(key));
140
+ }
141
+ export async function saveAgentCandidate(key: string, candidate: unknown) {
142
+ await checkDirectory();
143
+ const target = candidatePath(key);
144
+ const existing = await readPrivate(target);
145
+ if (existing !== undefined) {
146
+ if (JSON.stringify(existing) !== JSON.stringify(candidate))
147
+ throw new Error("Agent candidate changed before save; existing evidence retained.");
148
+ return;
149
+ }
150
+ await writeJson(target, candidate, { maxBytes: AGENT_OUTPUT_BYTES * 2 });
151
+ }
@@ -0,0 +1,91 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { CommandOptions } from "../commands/command-types.js";
3
+ import { withLock } from "../state/lock.js";
4
+ import { setSyncStatus } from "../ui/sync-status.js";
5
+ import { AGENT_MAX_ATTEMPTS } from "./agent-policy.js";
6
+ import { type AgentAcceptance, AgentResolutionRequired } from "./agent-request.js";
7
+ import { ContinuousError } from "./continuous-errors.js";
8
+
9
+ /** All callers retain their existing state-directory access guard. Off stays lazy. */
10
+ export async function withAgentSyncLock<T>(
11
+ ctx: ExtensionContext,
12
+ options: CommandOptions,
13
+ run: (options: CommandOptions) => Promise<T>,
14
+ ): Promise<T> {
15
+ let request: AgentResolutionRequired;
16
+ const background = options.background;
17
+ try {
18
+ return await withLock("sync", () => run(options));
19
+ } catch (error) {
20
+ if (!(error instanceof AgentResolutionRequired)) throw error;
21
+ request = error;
22
+ }
23
+ const { withAgentClaim, readAgentCandidate, saveAgentCandidate } = await import("./agent-store.js");
24
+ const { parseAgentCandidate } = await import("./agent-candidate.js");
25
+ const { runAgentResolver } = await import("./agent-runner.js");
26
+ return withAgentClaim(async (assertOwner) => {
27
+ const clearStatus = () => setSyncStatus(ctx, undefined);
28
+ const onAbort = () => {
29
+ try {
30
+ clearStatus();
31
+ } catch {
32
+ /* UI can already be disposed. */
33
+ }
34
+ };
35
+ options.signal?.addEventListener("abort", onAbort, { once: true });
36
+ try {
37
+ options.signal?.throwIfAborted();
38
+ for (let attempt = 0; attempt < AGENT_MAX_ATTEMPTS; attempt++) {
39
+ const validate = () =>
40
+ withLock("agent-validate", async () => {
41
+ options.signal?.throwIfAborted();
42
+ await assertOwner();
43
+ await request.validate();
44
+ options.signal?.throwIfAborted();
45
+ });
46
+ await validate();
47
+ setSyncStatus(ctx, "sync resolving");
48
+ const cached = await withLock("agent-cache-read", async () => {
49
+ await assertOwner();
50
+ return readAgentCandidate(request.key);
51
+ });
52
+ const raw =
53
+ cached === undefined
54
+ ? await runAgentResolver(
55
+ ctx,
56
+ request.evidence,
57
+ validate,
58
+ options.signal,
59
+ background ? () => background.reserve(request.key) : undefined,
60
+ )
61
+ : JSON.stringify(cached);
62
+ let candidate: ReturnType<typeof parseAgentCandidate>;
63
+ try {
64
+ candidate = parseAgentCandidate(raw, request.evidence);
65
+ } catch {
66
+ throw new ContinuousError("model", "Agent mode paused: invalid resolver output; evidence retained.");
67
+ }
68
+ await validate();
69
+ await withLock("agent-cache-save", async () => {
70
+ await assertOwner();
71
+ await saveAgentCandidate(request.key, candidate);
72
+ });
73
+ const acceptance: AgentAcceptance = { key: request.key, candidate };
74
+ try {
75
+ return await withLock("sync", async () => {
76
+ await assertOwner();
77
+ options.signal?.throwIfAborted();
78
+ return run({ ...options, agentAcceptance: acceptance });
79
+ });
80
+ } catch (error) {
81
+ if (!(error instanceof AgentResolutionRequired)) throw error;
82
+ request = error;
83
+ }
84
+ }
85
+ throw new ContinuousError("stale", "Agent mode paused: content changed repeatedly; retry later.");
86
+ } finally {
87
+ options.signal?.removeEventListener("abort", onAbort);
88
+ if (!options.signal?.aborted) clearStatus();
89
+ }
90
+ });
91
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { agentDir } from "../snapshot/session-paths.js";
6
+ import { addTopLevelCaseVariantDeletes } from "../snapshot/snapshot-apply.js";
7
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
8
+ import { throwIfAborted } from "./signals.js";
9
+
10
+ export interface CaseCleanupReview {
11
+ path: string;
12
+ fingerprint: string;
13
+ }
14
+
15
+ /** Inspect the apply engine's additional alias removals, which logical snapshots omit. */
16
+ export async function inspectCaseCleanup(snapshot: Snapshot, signal?: AbortSignal): Promise<CaseCleanupReview[]> {
17
+ throwIfAborted(signal);
18
+ const root = agentDir();
19
+ const plan = await addTopLevelCaseVariantDeletes(root, { writes: [], deletes: [] }, snapshot);
20
+ throwIfAborted(signal);
21
+ const review: CaseCleanupReview[] = [];
22
+ for (const target of plan.deletes.sort()) {
23
+ const stat = await fs.lstat(target);
24
+ throwIfAborted(signal);
25
+ const hash = createHash("sha256");
26
+ if (stat.isSymbolicLink()) hash.update(await fs.readlink(target));
27
+ else {
28
+ if (!stat.isFile()) throw new Error("Case-variant path is no longer a regular file or symlink.");
29
+ const handle = await fs.open(
30
+ target,
31
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
32
+ );
33
+ try {
34
+ throwIfAborted(signal);
35
+ const opened = await handle.stat();
36
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino)
37
+ throw new Error("Case-variant file changed while preparing review.");
38
+ for await (const bytes of handle.createReadStream({ autoClose: false, signal })) {
39
+ throwIfAborted(signal);
40
+ hash.update(bytes);
41
+ }
42
+ } finally {
43
+ await handle.close();
44
+ }
45
+ }
46
+ throwIfAborted(signal);
47
+ review.push({
48
+ path: path.basename(target),
49
+ fingerprint: `${stat.dev}:${stat.ino}:${stat.mode}:${hash.digest("hex")}`,
50
+ });
51
+ }
52
+ return review;
53
+ }
54
+
55
+ export async function revalidateCaseCleanup(
56
+ expected: readonly CaseCleanupReview[],
57
+ snapshot: Snapshot,
58
+ signal?: AbortSignal,
59
+ ) {
60
+ const current = await inspectCaseCleanup(snapshot, signal);
61
+ if (JSON.stringify(current) !== JSON.stringify(expected))
62
+ throw new Error("Case-variant paths changed during transfer review; reopen the preview.");
63
+ }
@@ -12,7 +12,9 @@ import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
12
12
  import { ensureStateDir, readStateForConfig } from "../state/sync-state-store.js";
13
13
  import { configureSyncStatus, setSyncStatus } from "../ui/sync-status.js";
14
14
  import { safeTerminalText } from "../ui/terminal-text.js";
15
+ import { startupTransferEnabled } from "./agent-policy.js";
15
16
  import { throwIfAborted } from "./signals.js";
17
+ import { createStartupTimings } from "./startup-timings.js";
16
18
  import { errorMessage } from "./sync-errors.js";
17
19
  import type { SyncLoaders } from "./sync-loaders.js";
18
20
  import { captureMutationOwner } from "./sync-local.js";
@@ -28,44 +30,55 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
28
30
  };
29
31
 
30
32
  export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
31
- throwIfAborted(signal);
32
- const stateNotice = stateDirectoryMigrationNotice();
33
- if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
34
- // Recovery can restore managed files: finish it before Pi accepts user edits.
35
- // Unlike remote inspection, it must never be detached behind startup.
36
- const validateMutation = captureMutationOwner(ctx, signal);
37
- const currentFile = ctx.sessionManager.getSessionFile?.();
38
- await recoverSnapshotTransactionsOnStartup({
39
- signal,
40
- sessionDir: sessionDirFromContext(ctx),
41
- protectedTargets: currentFile ? [currentFile] : [],
42
- validateMutation,
43
- });
44
- throwIfAborted(signal);
33
+ const timings = createStartupTimings();
34
+ let debug = false;
35
+ let outcome: "completed" | "stopped" = "stopped";
45
36
  try {
46
- const settings = await readLocalConfigObject();
47
37
  throwIfAborted(signal);
48
- const names = settings ? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right)) : [];
49
- setSyncSetupCompletions(names);
50
- configureSyncStatus(ctx, settings?.showStatus ?? true);
51
- } catch {
52
- if (signal.aborted) return;
53
- setSyncSetupCompletions([]);
54
- }
55
- const migrationNotice = consumeLocalConfigMigrationNotice();
56
- if (migrationNotice && ctx.hasUI) ctx.ui.notify(migrationNotice, "warning");
57
- throwIfAborted(signal);
58
- if (!ctx.hasUI) return;
59
- try {
60
- const config = await loadConfig();
38
+ const stateNotice = stateDirectoryMigrationNotice();
39
+ if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
40
+ // Recovery can restore managed files: finish it before Pi accepts user edits.
41
+ // Unlike remote inspection, it must never be detached behind startup.
42
+ const validateMutation = captureMutationOwner(ctx, signal);
43
+ const currentFile = ctx.sessionManager.getSessionFile?.();
44
+ await timings.measure("transaction recovery", () =>
45
+ recoverSnapshotTransactionsOnStartup({
46
+ signal,
47
+ sessionDir: sessionDirFromContext(ctx),
48
+ protectedTargets: currentFile ? [currentFile] : [],
49
+ validateMutation,
50
+ }),
51
+ );
61
52
  throwIfAborted(signal);
62
- configureSyncStatus(ctx, config.showStatus);
63
- return config.automatic || config.automaticTransfer ? config : undefined;
64
- } catch (error) {
53
+ try {
54
+ const settings = await timings.measure("settings read", () => readLocalConfigObject());
55
+ throwIfAborted(signal);
56
+ debug = settings?.debug ?? false;
57
+ const names = settings ? Object.keys(settings.syncSetups).sort((left, right) => left.localeCompare(right)) : [];
58
+ setSyncSetupCompletions(names);
59
+ configureSyncStatus(ctx, settings?.showStatus ?? true);
60
+ } catch {
61
+ if (signal.aborted) return;
62
+ setSyncSetupCompletions([]);
63
+ }
64
+ const migrationNotice = consumeLocalConfigMigrationNotice();
65
+ if (migrationNotice && ctx.hasUI) ctx.ui.notify(migrationNotice, "warning");
65
66
  throwIfAborted(signal);
66
- if (!isMissingConfigError(error)) {
67
- ctx.ui.notify(`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}`, "warning");
67
+ if (!ctx.hasUI) return;
68
+ try {
69
+ const config = await timings.measure("config resolution", () => loadConfig());
70
+ throwIfAborted(signal);
71
+ configureSyncStatus(ctx, config.showStatus);
72
+ outcome = "completed";
73
+ return config.automatic || startupTransferEnabled(config) ? config : undefined;
74
+ } catch (error) {
75
+ throwIfAborted(signal);
76
+ if (!isMissingConfigError(error)) {
77
+ ctx.ui.notify(`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}`, "warning");
78
+ }
68
79
  }
80
+ } finally {
81
+ timings.publish(ctx, debug, signal, "local initialization", outcome);
69
82
  }
70
83
  }
71
84
 
@@ -0,0 +1,85 @@
1
+ import { SyncBackendConflictError } from "../backends/sync-backend.js";
2
+
3
+ export type ContinuousErrorKind =
4
+ | "transient"
5
+ | "contention"
6
+ | "stale"
7
+ | "authentication"
8
+ | "storage"
9
+ | "unsafe"
10
+ | "model"
11
+ | "budget"
12
+ | "unknown";
13
+
14
+ /** Categories are safe to persist; provider messages and causes are not. */
15
+ export class ContinuousError extends Error {
16
+ constructor(
17
+ readonly kind: ContinuousErrorKind,
18
+ message: string,
19
+ readonly retryAfterMs?: number,
20
+ ) {
21
+ super(message);
22
+ this.name = "ContinuousError";
23
+ }
24
+ }
25
+
26
+ export function continuousFailure(error: unknown): { kind: ContinuousErrorKind; retry: boolean; delay?: number } {
27
+ const seen = new Set<unknown>();
28
+ for (let current = error; current && !seen.has(current) && seen.size < 8; ) {
29
+ seen.add(current);
30
+ if (current instanceof ContinuousError)
31
+ return {
32
+ kind: current.kind,
33
+ retry: ["transient", "contention", "stale", "budget"].includes(current.kind),
34
+ delay: current.retryAfterMs,
35
+ };
36
+ if (current instanceof SyncBackendConflictError) return { kind: "stale", retry: true };
37
+ if (typeof current !== "object") break;
38
+ const value = current as {
39
+ status?: unknown;
40
+ code?: unknown;
41
+ name?: unknown;
42
+ cause?: unknown;
43
+ retryAfterMs?: unknown;
44
+ };
45
+ if (value.status === 401 || value.status === 403) return { kind: "authentication", retry: false };
46
+ if (
47
+ value.status === 408 ||
48
+ value.status === 429 ||
49
+ (typeof value.status === "number" && Number.isInteger(value.status) && value.status >= 500 && value.status <= 599)
50
+ )
51
+ return { kind: "transient", retry: true, delay: boundedRetryDelay(value.retryAfterMs) };
52
+ if (value.code === "ELOCKED") return { kind: "contention", retry: true };
53
+ if (value.code === "ECOMPROMISED") return { kind: "stale", retry: true };
54
+ if (
55
+ value.name === "TimeoutError" ||
56
+ [
57
+ "ECONNRESET",
58
+ "ECONNREFUSED",
59
+ "ETIMEDOUT",
60
+ "ENETUNREACH",
61
+ "EHOSTUNREACH",
62
+ "ENOTFOUND",
63
+ "EAI_AGAIN",
64
+ "UND_ERR_CONNECT_TIMEOUT",
65
+ "UND_ERR_SOCKET",
66
+ ].includes(String(value.code))
67
+ )
68
+ return { kind: "transient", retry: true };
69
+ if (["EACCES", "EPERM", "ENOSPC", "EROFS", "EIO"].includes(String(value.code)))
70
+ return { kind: "storage", retry: false };
71
+ current = value.cause;
72
+ }
73
+ return { kind: "unknown", retry: false };
74
+ }
75
+
76
+ export function boundedRetryDelay(value: unknown): number | undefined {
77
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.min(value, 300_000) : undefined;
78
+ }
79
+
80
+ export function retryAfter(response: Response): number | undefined {
81
+ const value = response.headers.get("retry-after");
82
+ if (!value) return;
83
+ const seconds = Number(value);
84
+ return boundedRetryDelay(Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now());
85
+ }