@narumitw/pi-sync 0.54.3 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +38 -11
  2. package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
  3. package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
  4. package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
  5. package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
  6. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
  7. package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
  8. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
  9. package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
  10. package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
  11. package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
  12. package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
  13. package/dist/chunks/{chunk-GC3QOL6J.ts → chunk-AAEEHR7H.ts} +31 -87
  14. package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
  15. package/dist/chunks/{chunk-R35N4CL3.ts → chunk-B4QZBDBT.ts} +2 -2
  16. package/dist/chunks/{chunk-KGE47GCZ.ts → chunk-CRKTAPSL.ts} +8 -8
  17. package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
  18. package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
  19. package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
  20. package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
  21. package/dist/chunks/{chunk-HMXUKU43.ts → chunk-EUMINX7L.ts} +6 -6
  22. package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
  23. package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
  24. package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
  25. package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
  26. package/dist/chunks/{chunk-YTHQEW35.ts → chunk-GP5DU62N.ts} +8 -9
  27. package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
  28. package/dist/chunks/{chunk-AAZDBIBJ.ts → chunk-GSNPASKB.ts} +2 -2
  29. package/dist/chunks/{chunk-E6ONFG57.ts → chunk-IWZH5KYS.ts} +11 -8
  30. package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
  31. package/dist/chunks/{chunk-CAGJOKB7.ts → chunk-JJTARUSO.ts} +15 -14
  32. package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
  33. package/dist/chunks/{chunk-ITFR6NNQ.ts → chunk-NLWS55B6.ts} +2 -1
  34. package/dist/chunks/{chunk-ITFR6NNQ.ts.map → chunk-NLWS55B6.ts.map} +2 -2
  35. package/dist/chunks/{chunk-2FETCGGT.ts → chunk-OEWQVWLC.ts} +4 -4
  36. package/dist/chunks/{chunk-35GM6XKM.ts → chunk-OTZRCKYP.ts} +128 -382
  37. package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
  38. package/dist/chunks/{chunk-XU2VXGQV.ts → chunk-OWBAZA6J.ts} +14 -8
  39. package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
  40. package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
  41. package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
  42. package/dist/chunks/chunk-QGK445K7.ts +21 -0
  43. package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
  44. package/dist/chunks/{chunk-KATKRR3D.ts → chunk-QO333NM4.ts} +32 -11
  45. package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
  46. package/dist/chunks/{chunk-I4JYVFUS.ts → chunk-QSHMOL3I.ts} +21 -22
  47. package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
  48. package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
  49. package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
  50. package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
  51. package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
  52. package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
  53. package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
  54. package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
  55. package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
  56. package/dist/chunks/{chunk-VCL7TZYW.ts → chunk-UPNVIUBN.ts} +3 -3
  57. package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
  58. package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
  59. package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
  60. package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
  61. package/dist/chunks/{chunk-A3TFXY4O.ts → chunk-YK52VDXG.ts} +6 -5
  62. package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
  63. package/dist/chunks/config-KGIYTTXQ.ts +37 -0
  64. package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
  65. package/dist/chunks/{conflict-review-ZOTOHL2E.ts → conflict-review-6WIEQLXW.ts} +31 -25
  66. package/dist/chunks/{conflict-review-ZOTOHL2E.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
  67. package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
  68. package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
  69. package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
  70. package/dist/chunks/{file-selection-UNHBVVON.ts → file-selection-3JJYOVNR.ts} +5 -5
  71. package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
  72. package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
  73. package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
  74. package/dist/chunks/{manager-ui-ZIN4BUSF.ts → manager-ui-KRAW3NE2.ts} +184 -45
  75. package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
  76. package/dist/chunks/{merge-baseline-store-ICXVTCML.ts → merge-baseline-store-RFCAH7KL.ts} +5 -5
  77. package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts → remote-selection-ui-5725JDNC.ts} +9 -8
  78. package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
  79. package/dist/chunks/{s3-backend-Y4P3O5B5.ts → s3-backend-Y7WSCLJI.ts} +38 -38
  80. package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
  81. package/dist/chunks/{setup-switch-ZMW5C4M7.ts → setup-switch-E7PHKI6J.ts} +7 -6
  82. package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
  83. package/dist/chunks/{setup-wizard-VW7F3JN4.ts → setup-wizard-65DROCIJ.ts} +14 -13
  84. package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
  85. package/dist/chunks/{sync-inspection-3EMS2J5B.ts → sync-inspection-XFJOAMJD.ts} +8 -8
  86. package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
  87. package/dist/chunks/{sync-operations-2CWST333.ts → sync-operations-HDUDT5PD.ts} +62 -63
  88. package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
  89. package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
  90. package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
  91. package/dist/index.ts +165 -85
  92. package/dist/index.ts.map +3 -3
  93. package/docs/agent-mode-verification.md +99 -0
  94. package/docs/continuous-sync-verification.md +87 -0
  95. package/docs/settings.md +32 -247
  96. package/package.json +11 -5
  97. package/skills/configure-pi-sync/SKILL.md +54 -0
  98. package/skills/configure-pi-sync/references/configuration.md +650 -0
  99. package/src/backends/git/git-backend.ts +1 -4
  100. package/src/backends/git/git-runner.ts +1 -5
  101. package/src/backends/s3/s3-backend.ts +3 -25
  102. package/src/backends/s3/s3-client.ts +14 -2
  103. package/src/backends/snapshot-pointer.ts +27 -0
  104. package/src/backends/webdav/webdav-backend.ts +3 -29
  105. package/src/backends/webdav/webdav-client.ts +11 -7
  106. package/src/commands/command-execution.ts +5 -2
  107. package/src/commands/command-handler.ts +15 -3
  108. package/src/commands/command-types.ts +6 -0
  109. package/src/commands/command.ts +1 -0
  110. package/src/settings/config.ts +7 -0
  111. package/src/settings/settings-store.ts +0 -34
  112. package/src/settings/settings-types.ts +8 -0
  113. package/src/settings/settings-validation.ts +1 -1
  114. package/src/snapshot/snapshot-codec.ts +1 -5
  115. package/src/state/lock.ts +13 -10
  116. package/src/state/merge-baseline-store.ts +2 -2
  117. package/src/state/state-directory.ts +8 -1
  118. package/src/sync/agent-candidate.ts +251 -0
  119. package/src/sync/agent-plan.ts +87 -0
  120. package/src/sync/agent-policy.ts +13 -0
  121. package/src/sync/agent-request.ts +17 -0
  122. package/src/sync/agent-runner.ts +188 -0
  123. package/src/sync/agent-store.ts +151 -0
  124. package/src/sync/agent-sync.ts +91 -0
  125. package/src/sync/automatic-sync.ts +2 -1
  126. package/src/sync/continuous-errors.ts +85 -0
  127. package/src/sync/continuous-lease.ts +221 -0
  128. package/src/sync/continuous-record.ts +179 -0
  129. package/src/sync/continuous-storage.ts +55 -0
  130. package/src/sync/continuous-sync.ts +516 -0
  131. package/src/sync/continuous-watch.ts +59 -0
  132. package/src/sync/merged-sync.ts +70 -12
  133. package/src/sync/remote-snapshot.ts +2 -3
  134. package/src/sync/settings-merge.ts +19 -3
  135. package/src/sync/setup-switch.ts +1 -5
  136. package/src/sync/startup-check.ts +15 -11
  137. package/src/sync/sync-inspection.ts +8 -1
  138. package/src/sync/sync-mutations.ts +6 -17
  139. package/src/sync/sync-queries.ts +5 -0
  140. package/src/sync/text-merge.ts +3 -3
  141. package/src/sync-extension.ts +75 -19
  142. package/src/ui/agent-mode-setting.ts +91 -0
  143. package/src/ui/manager-state.ts +2 -0
  144. package/src/ui/remote-selection-ui.ts +5 -5
  145. package/src/ui/settings-ui.ts +83 -1
  146. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  147. package/src/ui/setup/setup-name-ui.ts +2 -5
  148. package/src/ui/setup/setup-switcher.ts +2 -0
  149. package/src/ui/sync-setups-ui.ts +3 -5
  150. package/dist/chunks/chunk-26PMXY7E.ts +0 -362
  151. package/dist/chunks/chunk-26PMXY7E.ts.map +0 -7
  152. package/dist/chunks/chunk-35GM6XKM.ts.map +0 -7
  153. package/dist/chunks/chunk-A3TFXY4O.ts.map +0 -7
  154. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  155. package/dist/chunks/chunk-CAGJOKB7.ts.map +0 -7
  156. package/dist/chunks/chunk-E6ONFG57.ts.map +0 -7
  157. package/dist/chunks/chunk-GC3QOL6J.ts.map +0 -7
  158. package/dist/chunks/chunk-I4JYVFUS.ts.map +0 -7
  159. package/dist/chunks/chunk-KATKRR3D.ts.map +0 -7
  160. package/dist/chunks/chunk-XU2VXGQV.ts.map +0 -7
  161. package/dist/chunks/chunk-YTHQEW35.ts.map +0 -7
  162. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  163. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  164. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +0 -7
  165. package/dist/chunks/s3-backend-Y4P3O5B5.ts.map +0 -7
  166. package/dist/chunks/setup-wizard-VW7F3JN4.ts.map +0 -7
  167. package/dist/chunks/sync-operations-2CWST333.ts.map +0 -7
  168. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  169. /package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  170. /package/dist/chunks/{chunk-R35N4CL3.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  171. /package/dist/chunks/{chunk-KGE47GCZ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  172. /package/dist/chunks/{chunk-HMXUKU43.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-AAZDBIBJ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-2FETCGGT.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-VCL7TZYW.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  177. /package/dist/chunks/{merge-baseline-store-ICXVTCML.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  178. /package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  179. /package/dist/chunks/{file-selection-UNHBVVON.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  180. /package/dist/chunks/{setup-switch-ZMW5C4M7.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  181. /package/dist/chunks/{sync-inspection-3EMS2J5B.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -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
+ }
@@ -12,6 +12,7 @@ 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";
16
17
  import { createStartupTimings } from "./startup-timings.js";
17
18
  import { errorMessage } from "./sync-errors.js";
@@ -69,7 +70,7 @@ export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
69
70
  throwIfAborted(signal);
70
71
  configureSyncStatus(ctx, config.showStatus);
71
72
  outcome = "completed";
72
- return config.automatic || config.automaticTransfer ? config : undefined;
73
+ return config.automatic || startupTransferEnabled(config) ? config : undefined;
73
74
  } catch (error) {
74
75
  throwIfAborted(signal);
75
76
  if (!isMissingConfigError(error)) {
@@ -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
+ }
@@ -0,0 +1,221 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, readFileSync, rmdirSync, type Stats, stat } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import lockfile from "proper-lockfile";
6
+ import { withLock } from "../state/lock.js";
7
+ import { LOCKFILE_FS_ADAPTER } from "../state/lockfile-fs.js";
8
+ import {
9
+ continuousRoot,
10
+ prepareContinuousRoot,
11
+ readContinuousPrivate,
12
+ writeContinuousPrivate,
13
+ } from "./continuous-storage.js";
14
+
15
+ const STALE_MS = 30_000;
16
+ const localLeases = new Set<string>();
17
+ const futureGuards = new Map<string, { identity: string; since: number }>();
18
+ interface Owner {
19
+ version: 1;
20
+ id: string;
21
+ pid: number;
22
+ binding: string;
23
+ }
24
+ export interface ContinuousLease {
25
+ signal: AbortSignal;
26
+ assert(): void;
27
+ release(): Promise<void>;
28
+ }
29
+ function validOwner(value: unknown): value is Owner {
30
+ if (!value || typeof value !== "object") return false;
31
+ const owner = value as Owner;
32
+ return (
33
+ Object.keys(owner).sort().join(",") === "binding,id,pid,version" &&
34
+ owner.version === 1 &&
35
+ typeof owner.id === "string" &&
36
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(owner.id) &&
37
+ Number.isSafeInteger(owner.pid) &&
38
+ owner.pid > 0 &&
39
+ owner.pid <= 2147483647 &&
40
+ typeof owner.binding === "string" &&
41
+ /^[a-f0-9]{64}$/u.test(owner.binding)
42
+ );
43
+ }
44
+
45
+ /** Advisory only: never authority for a transfer or lease takeover. */
46
+ export async function continuousLeaseOwner(binding: string): Promise<string> {
47
+ const owner = await readContinuousPrivate("owner.json");
48
+ if (owner === undefined) return "Local scheduler lease: not observed; waiting for an eligible session";
49
+ if (!validOwner(owner)) throw new Error("Invalid continuous sync owner");
50
+ try {
51
+ const guard = await fs.lstat(path.join(continuousRoot(), "scheduler.lock"));
52
+ if (
53
+ guard.isDirectory() &&
54
+ !guard.isSymbolicLink() &&
55
+ Date.now() - guard.mtimeMs <= STALE_MS &&
56
+ owner.binding === binding
57
+ )
58
+ return `Local scheduler lease: observed for pid ${owner.pid}; this can be another session`;
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
61
+ }
62
+ return "Local scheduler lease: not observed; waiting for an eligible session";
63
+ }
64
+
65
+ /** Caller holds state-directory access during acquisition; the renewable guard prevents live migration. */
66
+ export async function acquireContinuousLease(binding: string): Promise<ContinuousLease | undefined> {
67
+ await prepareContinuousRoot();
68
+ const file = path.join(continuousRoot(), "scheduler");
69
+ const guardPath = `${file}.lock`;
70
+ if (localLeases.has(file)) return undefined;
71
+ const previousOwner = await readContinuousPrivate("owner.json");
72
+ if (previousOwner !== undefined && !validOwner(previousOwner))
73
+ throw new Error("Invalid continuous sync owner; evidence retained.");
74
+ if (localLeases.has(file)) return undefined;
75
+ localLeases.add(file);
76
+ const acquiredAt = performance.now();
77
+ const ownerPath = path.join(continuousRoot(), "owner.json");
78
+ const controller = new AbortController();
79
+ const owner: Owner = { version: 1, id: randomUUID(), pid: process.pid, binding };
80
+ let identity: { dev: number; ino: number } | undefined;
81
+ let published = false;
82
+ // proper-lockfile also uses rmdirSync at process exit. Fence both cleanup paths.
83
+ const removeGuard = (target: string) => {
84
+ if (identity) {
85
+ const current = lstatSync(target);
86
+ if (current.dev !== identity.dev || current.ino !== identity.ino) return;
87
+ }
88
+ let current: unknown;
89
+ try {
90
+ const record = lstatSync(ownerPath);
91
+ if (
92
+ !record.isFile() ||
93
+ record.isSymbolicLink() ||
94
+ record.nlink !== 1 ||
95
+ record.size > 4096 ||
96
+ (process.platform !== "win32" && record.mode & 0o077)
97
+ )
98
+ return;
99
+ current = JSON.parse(readFileSync(ownerPath, "utf8"));
100
+ } catch (error) {
101
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
102
+ }
103
+ if (published && current === undefined) return;
104
+ if (
105
+ current !== undefined &&
106
+ (!validOwner(current) ||
107
+ (current.id !== owner.id && (published || current.id !== (previousOwner as Owner | undefined)?.id)))
108
+ )
109
+ return;
110
+ rmdirSync(target);
111
+ };
112
+ const adapter = {
113
+ ...LOCKFILE_FS_ADAPTER,
114
+ stat: (target: string, callback: (error: NodeJS.ErrnoException | null, value?: Stats) => void) =>
115
+ stat(target, (error, value) => {
116
+ if (!error && value.mtimeMs > Date.now() + STALE_MS) {
117
+ const identity = `${value.dev}:${value.ino}:${value.mtimeMs}`;
118
+ const previous = futureGuards.get(target);
119
+ if (!previous || previous.identity !== identity)
120
+ futureGuards.set(target, { identity, since: performance.now() });
121
+ else if (performance.now() - previous.since > STALE_MS) value.mtime = new Date(Date.now() - STALE_MS - 1);
122
+ } else futureGuards.delete(target);
123
+ callback(error, value);
124
+ }),
125
+ rmdirSync: removeGuard,
126
+ rmdir: (target: string, callback: (error?: NodeJS.ErrnoException | null) => void) => {
127
+ try {
128
+ removeGuard(target);
129
+ callback();
130
+ } catch (error) {
131
+ callback(error as NodeJS.ErrnoException);
132
+ }
133
+ },
134
+ };
135
+ let compromised = false;
136
+ let unlock: () => Promise<void>;
137
+ try {
138
+ unlock = await lockfile.lock(file, {
139
+ fs: adapter,
140
+ realpath: false,
141
+ stale: STALE_MS,
142
+ update: 10_000,
143
+ retries: 0,
144
+ onCompromised: () => {
145
+ compromised = true;
146
+ controller.abort(new DOMException("Continuous sync ownership lost", "AbortError"));
147
+ },
148
+ });
149
+ } catch (error) {
150
+ localLeases.delete(file);
151
+ if ((error as NodeJS.ErrnoException).code === "ELOCKED") return undefined;
152
+ throw error;
153
+ }
154
+ let released = false;
155
+ const release = async () => {
156
+ if (released) return;
157
+ released = true;
158
+ controller.abort(new DOMException("Continuous sync owner released", "AbortError"));
159
+ try {
160
+ if (!compromised) await unlock();
161
+ } finally {
162
+ localLeases.delete(file);
163
+ futureGuards.delete(guardPath);
164
+ }
165
+ };
166
+ try {
167
+ identity = await fs.lstat(guardPath);
168
+ await withLock("continuous-owner", async () => {
169
+ const previous = await readContinuousPrivate("owner.json");
170
+ if (previous !== undefined && !validOwner(previous))
171
+ throw new Error("Invalid continuous sync owner; evidence retained.");
172
+ if (
173
+ (previous as Owner | undefined)?.id !== (previousOwner as Owner | undefined)?.id ||
174
+ performance.now() - acquiredAt > STALE_MS
175
+ )
176
+ throw new Error("Continuous lease generation changed before publication.");
177
+ const current = lstatSync(guardPath);
178
+ if (controller.signal.aborted || current.dev !== identity?.dev || current.ino !== identity.ino)
179
+ throw new Error("Continuous lease changed before publication.");
180
+ await writeContinuousPrivate("owner.json", owner);
181
+ published = true;
182
+ });
183
+ const assert = () => {
184
+ controller.signal.throwIfAborted();
185
+ try {
186
+ const root = lstatSync(continuousRoot());
187
+ if (!root.isDirectory() || root.isSymbolicLink() || (process.platform !== "win32" && root.mode & 0o077))
188
+ throw new Error("Unsafe lease root");
189
+ const stat = lstatSync(guardPath);
190
+ if (
191
+ stat.isSymbolicLink() ||
192
+ !stat.isDirectory() ||
193
+ stat.dev !== identity?.dev ||
194
+ stat.ino !== identity.ino ||
195
+ Date.now() - stat.mtimeMs > STALE_MS
196
+ )
197
+ throw new Error("Lease guard changed");
198
+ const record = lstatSync(ownerPath);
199
+ if (
200
+ !record.isFile() ||
201
+ record.isSymbolicLink() ||
202
+ record.nlink !== 1 ||
203
+ record.size > 4096 ||
204
+ (process.platform !== "win32" && record.mode & 0o077)
205
+ )
206
+ throw new Error("Unsafe owner record");
207
+ const value: unknown = JSON.parse(readFileSync(ownerPath, "utf8"));
208
+ if (!validOwner(value) || value.id !== owner.id || value.binding !== binding || value.pid !== process.pid)
209
+ throw new Error("Lease owner changed");
210
+ } catch {
211
+ controller.abort(new DOMException("Continuous sync ownership lost", "AbortError"));
212
+ controller.signal.throwIfAborted();
213
+ }
214
+ };
215
+ assert();
216
+ return { signal: controller.signal, assert, release };
217
+ } catch (error) {
218
+ await release();
219
+ throw error;
220
+ }
221
+ }