@alfe.ai/openclaw-sync 0.3.5 → 0.3.7

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.
package/dist/plugin2.js CHANGED
@@ -1,10 +1,12 @@
1
- import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir } from "./sync-engine.js";
1
+ import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir, y as validatePrivateRelativePath } from "./sync-engine.js";
2
2
  import { createRequire } from "node:module";
3
- import { mkdir, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readdir, rm, rmdir, unlink, writeFile } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
- import { dirname, join, normalize, relative, sep } from "node:path";
5
+ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
6
+ import { createLogger } from "@auriclabs/logger";
6
7
  import { watch } from "chokidar";
7
- import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, resolveConfig } from "@alfe.ai/config";
8
+ import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, deriveServiceWsUrl, resolveConfig } from "@alfe.ai/config";
9
+ import { connectToDaemon, getActivationKey, guardedStart, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
8
10
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
11
  //#region src/watcher.ts
10
12
  /**
@@ -13,6 +15,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
13
15
  * Uses chokidar to watch the workspace root, debounces per-file changes
14
16
  * by 2 seconds, and emits batches of changed paths.
15
17
  */
18
+ const WORKSPACE_IGNORE_FILE = ".alfesyncignore";
19
+ const log = createLogger("SyncWatcher");
16
20
  /**
17
21
  * Start watching a workspace for file changes.
18
22
  *
@@ -20,7 +24,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
20
24
  */
21
25
  async function startWatcher(options) {
22
26
  const { workspacePath, runtime = "openclaw", debounceMs = 2e3, onChanges } = options;
23
- const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
27
+ let ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
28
+ let stopped = false;
24
29
  const pending = /* @__PURE__ */ new Map();
25
30
  let batchPaths = /* @__PURE__ */ new Set();
26
31
  let flushTimer = null;
@@ -31,11 +36,12 @@ async function startWatcher(options) {
31
36
  if (batchPaths.size === 0) return;
32
37
  const paths = [...batchPaths];
33
38
  batchPaths = /* @__PURE__ */ new Set();
34
- onChanges(paths);
39
+ Promise.resolve(onChanges(paths)).catch((error) => {
40
+ log.error({ err: error }, "Sync watcher change handler failed");
41
+ });
35
42
  }, debounceMs);
36
43
  }
37
- function handleChange(absolutePath) {
38
- const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
44
+ function queueChange(relativePath) {
39
45
  if (shouldIgnore(relativePath, ignorePatterns)) return;
40
46
  const existingTimer = pending.get(relativePath);
41
47
  if (existingTimer) clearTimeout(existingTimer);
@@ -59,16 +65,40 @@ async function startWatcher(options) {
59
65
  return shouldIgnoreDir(rel, ignorePatterns);
60
66
  }
61
67
  });
68
+ let ignoreReloadTail = Promise.resolve();
69
+ function handleChange(absolutePath) {
70
+ const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
71
+ if (relativePath === WORKSPACE_IGNORE_FILE) {
72
+ ignoreReloadTail = ignoreReloadTail.then(async () => {
73
+ const nextRules = await loadIgnorePatterns(workspacePath, runtime);
74
+ if (stopped) return;
75
+ ignorePatterns = nextRules;
76
+ watcher.unwatch(workspacePath);
77
+ watcher.add(workspacePath);
78
+ queueChange(relativePath);
79
+ }).catch((error) => {
80
+ log.error({ err: error }, "Failed to reload sync ignore rules");
81
+ });
82
+ return;
83
+ }
84
+ queueChange(relativePath);
85
+ }
62
86
  watcher.on("add", handleChange);
63
87
  watcher.on("change", handleChange);
64
88
  watcher.on("unlink", handleChange);
89
+ watcher.on("error", (error) => {
90
+ log.error({ err: error }, "Sync watcher failed");
91
+ });
65
92
  return async () => {
93
+ stopped = true;
66
94
  for (const timer of pending.values()) clearTimeout(timer);
67
95
  pending.clear();
68
96
  if (flushTimer) {
69
97
  clearTimeout(flushTimer);
70
98
  flushTimer = null;
71
99
  }
100
+ batchPaths.clear();
101
+ await ignoreReloadTail;
72
102
  await watcher.close();
73
103
  };
74
104
  }
@@ -86,25 +116,56 @@ async function startWatcher(options) {
86
116
  * `AgentApiClient.sharedDownloadUrl`).
87
117
  */
88
118
  const MAX_SHARED_FILE_SIZE = 100 * 1024 * 1024;
119
+ const SHARED_LIST_PAGE_SIZE = 500;
120
+ const MAX_SHARED_LIST_PAGES = 100;
121
+ function validateScope(scope) {
122
+ const candidate = scope;
123
+ if (candidate === null || candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) throw new Error("Invalid shared sync scope");
124
+ return candidate;
125
+ }
126
+ function validateSharedFilePath(filePath) {
127
+ if (filePath.length === 0 || filePath.length > 1024 || isAbsolute(filePath) || filePath.includes("\\") || filePath.includes("\0")) throw new Error("Invalid shared sync file path");
128
+ const segments = filePath.split("/");
129
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("Invalid shared sync file path");
130
+ return segments.join("/");
131
+ }
89
132
  /** Throw if `resolvedPath` would escape `baseDir`. */
90
133
  function assertContained(baseDir, resolvedPath) {
91
134
  const normalizedBase = normalize(baseDir) + sep;
92
135
  const normalizedPath = normalize(resolvedPath);
93
136
  if (!normalizedPath.startsWith(normalizedBase) && normalizedPath !== normalize(baseDir)) throw new Error(`Path traversal blocked: ${resolvedPath} escapes ${baseDir}`);
94
137
  }
138
+ async function assertNoSymlinkTraversal(baseDir, relativePath) {
139
+ const canonical = validateSharedFilePath(relativePath);
140
+ const resolvedPath = join(baseDir, canonical);
141
+ assertContained(baseDir, resolvedPath);
142
+ let current = normalize(baseDir);
143
+ for (const segment of canonical.split("/")) {
144
+ current = join(current, segment);
145
+ try {
146
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("Shared sync path crosses a symbolic link");
147
+ } catch (error) {
148
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") break;
149
+ throw error;
150
+ }
151
+ }
152
+ return resolvedPath;
153
+ }
95
154
  function createSharedSyncEngine(config, log) {
96
155
  let activeScopes = [];
97
156
  const sharedDir = join(config.workspacePath, "shared");
98
157
  function scopeDir(scope) {
158
+ validateScope(scope);
99
159
  if (scope.scopeType === "org") return join(sharedDir, "org");
100
160
  return join(sharedDir, scope.scopeType === "team" ? "teams" : "projects", scope.scopeId);
101
161
  }
102
162
  async function downloadFile(scope, filePath, localPath) {
103
- assertContained(scopeDir(scope), localPath);
163
+ const canonicalPath = validateSharedFilePath(filePath);
164
+ if (await assertNoSymlinkTraversal(scopeDir(scope), canonicalPath) !== normalize(localPath)) throw new Error("Shared sync local path does not match its canonical path");
104
165
  const { downloadUrl } = await config.client.sharedDownloadUrl({
105
166
  scope: scope.scopeType,
106
167
  scopeId: scope.scopeId,
107
- filePath
168
+ filePath: canonicalPath
108
169
  });
109
170
  const response = await fetch(downloadUrl);
110
171
  if (!response.ok) throw new Error(`Download failed: HTTP ${String(response.status)}`);
@@ -115,66 +176,147 @@ function createSharedSyncEngine(config, log) {
115
176
  await mkdir(dirname(localPath), { recursive: true });
116
177
  await writeFile(localPath, buffer);
117
178
  }
118
- async function syncScope(scope) {
119
- const dir = scopeDir(scope);
120
- await mkdir(dir, { recursive: true });
121
- try {
122
- const { files } = await config.client.sharedListFiles({
179
+ async function listRemoteFiles(scope) {
180
+ const files = /* @__PURE__ */ new Map();
181
+ const seenCursors = /* @__PURE__ */ new Set();
182
+ let cursor;
183
+ for (let page = 0; page < MAX_SHARED_LIST_PAGES; page++) {
184
+ const response = await config.client.sharedListFiles({
123
185
  scope: scope.scopeType,
124
- scopeId: scope.scopeId
186
+ scopeId: scope.scopeId,
187
+ limit: SHARED_LIST_PAGE_SIZE,
188
+ ...cursor ? { cursor } : {}
125
189
  });
126
- for (const file of files) {
127
- const localPath = join(dir, file.filePath);
190
+ for (const file of response.files) {
191
+ const filePath = validateSharedFilePath(file.filePath);
192
+ files.set(filePath, {
193
+ ...file,
194
+ filePath
195
+ });
196
+ }
197
+ if (!response.nextCursor) {
198
+ const paths = new Set(files.keys());
199
+ for (const filePath of paths) {
200
+ const segments = filePath.split("/");
201
+ for (let index = 1; index < segments.length; index++) if (paths.has(segments.slice(0, index).join("/"))) throw new Error("Shared file listing contains a file/directory collision");
202
+ }
203
+ return [...files.values()];
204
+ }
205
+ if (seenCursors.has(response.nextCursor)) throw new Error("Shared file listing returned a repeated cursor");
206
+ seenCursors.add(response.nextCursor);
207
+ cursor = response.nextCursor;
208
+ }
209
+ throw new Error(`Shared file listing exceeded ${String(MAX_SHARED_LIST_PAGES)} pages`);
210
+ }
211
+ async function pruneLocalMirror(dir, remotePaths) {
212
+ const remoteDirectories = /* @__PURE__ */ new Set();
213
+ for (const filePath of remotePaths) {
214
+ const segments = filePath.split("/");
215
+ for (let index = 1; index < segments.length; index++) remoteDirectories.add(segments.slice(0, index).join("/"));
216
+ }
217
+ const walk = async (currentDir, prefix) => {
218
+ const entries = await readdir(currentDir, { withFileTypes: true });
219
+ for (const entry of entries) {
220
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
221
+ const fullPath = join(currentDir, entry.name);
222
+ let canonicalPath = null;
128
223
  try {
129
- await downloadFile(scope, file.filePath, localPath);
130
- log.debug(`Shared sync: downloaded ${scope.scopeType}/${scope.scopeId}/${file.filePath}`);
131
- } catch (err) {
132
- log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
224
+ canonicalPath = validateSharedFilePath(relativePath);
225
+ } catch {}
226
+ if (entry.isSymbolicLink()) {
227
+ await unlink(fullPath);
228
+ log.warn(`Shared sync: removed symbolic link ${relativePath}`);
229
+ continue;
230
+ }
231
+ if (entry.isDirectory()) {
232
+ if (!canonicalPath) {
233
+ await rm(fullPath, {
234
+ recursive: true,
235
+ force: true
236
+ });
237
+ continue;
238
+ }
239
+ await walk(fullPath, canonicalPath);
240
+ if (!remoteDirectories.has(canonicalPath)) try {
241
+ await rmdir(fullPath);
242
+ } catch (error) {
243
+ if (!(error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY"))) throw error;
244
+ }
245
+ continue;
246
+ }
247
+ if (!canonicalPath || !remotePaths.has(canonicalPath)) {
248
+ await unlink(fullPath);
249
+ log.debug(`Shared sync: pruned stale local file ${relativePath}`);
133
250
  }
134
251
  }
252
+ };
253
+ await walk(dir, "");
254
+ }
255
+ async function syncScope(scope) {
256
+ const dir = scopeDir(scope);
257
+ await mkdir(dir, { recursive: true });
258
+ const files = await listRemoteFiles(scope);
259
+ await pruneLocalMirror(dir, new Set(files.map((file) => file.filePath)));
260
+ for (const file of files) try {
261
+ const filePath = validateSharedFilePath(file.filePath);
262
+ await downloadFile(scope, filePath, join(dir, filePath));
263
+ log.debug(`Shared sync: downloaded ${scope.scopeType}/${scope.scopeId}/${file.filePath}`);
135
264
  } catch (err) {
136
- log.error(`Shared sync: failed to list files for ${scope.scopeType}/${scope.scopeId}: ${err instanceof Error ? err.message : String(err)}`);
265
+ log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
137
266
  }
138
267
  }
139
268
  function parseScopedPath(filePath) {
140
- if (filePath.includes("..")) return null;
269
+ if (filePath.length > 2048 || filePath.includes("\\") || filePath.includes("\0")) return null;
141
270
  const orgMatch = /^shared\/org\/(.+)$/.exec(filePath);
142
271
  if (orgMatch) {
143
272
  const scope = activeScopes.find((s) => s.scopeType === "org");
144
- if (scope) return {
145
- scope,
146
- relativePath: orgMatch[1]
147
- };
273
+ if (scope) try {
274
+ return {
275
+ scope,
276
+ relativePath: validateSharedFilePath(orgMatch[1])
277
+ };
278
+ } catch {
279
+ return null;
280
+ }
148
281
  }
149
282
  const teamMatch = /^shared\/teams\/([^/]+)\/(.+)$/.exec(filePath);
150
283
  if (teamMatch) {
151
284
  const scope = activeScopes.find((s) => s.scopeType === "team" && s.scopeId === teamMatch[1]);
152
- if (scope) return {
153
- scope,
154
- relativePath: teamMatch[2]
155
- };
285
+ if (scope) try {
286
+ return {
287
+ scope,
288
+ relativePath: validateSharedFilePath(teamMatch[2])
289
+ };
290
+ } catch {
291
+ return null;
292
+ }
156
293
  }
157
294
  const projectMatch = /^shared\/projects\/([^/]+)\/(.+)$/.exec(filePath);
158
295
  if (projectMatch) {
159
296
  const scope = activeScopes.find((s) => s.scopeType === "project" && s.scopeId === projectMatch[1]);
160
- if (scope) return {
161
- scope,
162
- relativePath: projectMatch[2]
163
- };
297
+ if (scope) try {
298
+ return {
299
+ scope,
300
+ relativePath: validateSharedFilePath(projectMatch[2])
301
+ };
302
+ } catch {
303
+ return null;
304
+ }
164
305
  }
165
306
  return null;
166
307
  }
167
308
  return {
168
309
  async initialize(scopes) {
169
- activeScopes = [...scopes];
310
+ activeScopes = scopes.map(validateScope);
170
311
  log.info(`Shared sync: initializing with ${String(scopes.length)} scope(s)`);
171
312
  await mkdir(sharedDir, { recursive: true });
172
313
  for (const scope of scopes) await syncScope(scope);
173
314
  log.info("Shared sync: initialization complete");
174
315
  },
175
316
  async updateScopes(newScopes) {
317
+ const validatedScopes = newScopes.map(validateScope);
176
318
  const oldIds = new Set(activeScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
177
- const newIds = new Set(newScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
319
+ const newIds = new Set(validatedScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
178
320
  for (const scope of activeScopes) {
179
321
  const key = `${scope.scopeType}:${scope.scopeId}`;
180
322
  if (!newIds.has(key)) {
@@ -190,14 +332,14 @@ function createSharedSyncEngine(config, log) {
190
332
  }
191
333
  }
192
334
  }
193
- for (const scope of newScopes) {
335
+ for (const scope of validatedScopes) {
194
336
  const key = `${scope.scopeType}:${scope.scopeId}`;
195
337
  if (!oldIds.has(key)) {
196
338
  log.info(`Shared sync: new scope ${scope.scopeType}/${scope.scopeId} — syncing files`);
197
339
  await syncScope(scope);
198
340
  }
199
341
  }
200
- activeScopes = [...newScopes];
342
+ activeScopes = [...validatedScopes];
201
343
  },
202
344
  async handleNotification(filePath, eventType) {
203
345
  const parsed = parseScopedPath(filePath);
@@ -205,11 +347,8 @@ function createSharedSyncEngine(config, log) {
205
347
  log.debug(`Shared sync: ignoring notification for unknown path: ${filePath}`);
206
348
  return;
207
349
  }
208
- const dir = scopeDir(parsed.scope);
209
- const localPath = join(dir, parsed.relativePath);
210
- try {
211
- assertContained(dir, localPath);
212
- } catch {
350
+ const localPath = await assertNoSymlinkTraversal(scopeDir(parsed.scope), parsed.relativePath).catch(() => null);
351
+ if (!localPath) {
213
352
  log.warn(`Shared sync: path traversal blocked for ${filePath}`);
214
353
  return;
215
354
  }
@@ -301,9 +440,13 @@ const SYNC_CAPABILITIES = [
301
440
  "sync.pull",
302
441
  "sync.fullSync"
303
442
  ];
443
+ const SYNC_ACTIVATION_KEY = getActivationKey("sync");
304
444
  const SYNC_RELAY_RECONNECT_BASE_MS = 1e3;
305
445
  const SYNC_RELAY_RECONNECT_MAX_MS = 3e4;
306
446
  const SYNC_RELAY_DEBOUNCE_MS = 500;
447
+ const SYNC_RELAY_MAX_MESSAGE_BYTES = 64 * 1024;
448
+ const SYNC_RELAY_HANDSHAKE_TIMEOUT_MS = 1e4;
449
+ const REMOTE_DELETE_SUPPRESSION_MS = 3e4;
307
450
  let client = null;
308
451
  let agentId = null;
309
452
  let syncEngine = null;
@@ -318,7 +461,119 @@ let syncRelayWs = null;
318
461
  let syncRelayReconnectTimer = null;
319
462
  let syncRelayReconnectAttempt = 0;
320
463
  let syncRelayDebounceTimer = null;
464
+ let syncRelayGeneration = 0;
465
+ let syncRelayActive = false;
466
+ let syncOperationTail = Promise.resolve();
321
467
  const syncRelayPendingPaths = /* @__PURE__ */ new Map();
468
+ const remoteDeleteSuppressions = /* @__PURE__ */ new Map();
469
+ function enqueueSyncOperation(operation) {
470
+ const result = syncOperationTail.then(operation, operation);
471
+ syncOperationTail = result.then(() => void 0, () => void 0);
472
+ return result;
473
+ }
474
+ function parseRelayMessage(value) {
475
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
476
+ const candidate = value;
477
+ if (candidate.type === "PING") return { type: "PING" };
478
+ if (candidate.type === "SUBSCRIBE_ACK" && (candidate.status === "ok" || candidate.status === "error") && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && (candidate.message === void 0 || typeof candidate.message === "string" && candidate.message.length <= 1024)) return {
479
+ type: "SUBSCRIBE_ACK",
480
+ status: candidate.status,
481
+ agentId: candidate.agentId,
482
+ ...typeof candidate.message === "string" ? { message: candidate.message } : {}
483
+ };
484
+ if (candidate.type === "FILE_CHANGED" && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && typeof candidate.filePath === "string" && (candidate.eventType === "created" || candidate.eventType === "deleted") && (candidate.etag === void 0 || typeof candidate.etag === "string" && candidate.etag.length <= 256)) try {
485
+ const sharedSegments = candidate.filePath.split("/");
486
+ const isCanonicalSharedPath = sharedSegments[0] === "shared" && sharedSegments.length >= 3 && !sharedSegments.some((segment) => segment === "" || segment === "." || segment === "..");
487
+ const filePath = isCanonicalSharedPath ? candidate.filePath : validatePrivateRelativePath(candidate.filePath);
488
+ if (filePath.length > 1024 || filePath.includes("\0") || filePath.includes("\\") || (filePath === "shared" || filePath.startsWith("shared/")) && !isCanonicalSharedPath) return null;
489
+ return {
490
+ type: "FILE_CHANGED",
491
+ agentId: candidate.agentId,
492
+ filePath,
493
+ eventType: candidate.eventType,
494
+ ...typeof candidate.etag === "string" ? { etag: candidate.etag } : {}
495
+ };
496
+ } catch {
497
+ return null;
498
+ }
499
+ return null;
500
+ }
501
+ function parseSharedScopes(value) {
502
+ if (!Array.isArray(value) || value.length > 1e3) return null;
503
+ const scopes = [];
504
+ for (const item of value) {
505
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
506
+ const candidate = item;
507
+ if (candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) return null;
508
+ scopes.push({
509
+ scopeType: candidate.scopeType,
510
+ scopeId: candidate.scopeId,
511
+ name: candidate.name
512
+ });
513
+ }
514
+ return scopes;
515
+ }
516
+ async function runFullSync(engine, log, label) {
517
+ return enqueueSyncOperation(async () => {
518
+ if (syncEngine !== engine) return null;
519
+ try {
520
+ const result = await engine.fullSync({ quiet: true });
521
+ lastSyncResult = result;
522
+ log.info(`${label} complete: ${String(result.pushed)} pushed, ${String(result.pulled)} pulled`);
523
+ return result;
524
+ } catch (err) {
525
+ log.error(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
526
+ return null;
527
+ }
528
+ });
529
+ }
530
+ async function handleRealtimeChanges(paths, workspacePath, log) {
531
+ const engine = syncEngine;
532
+ const brake = deleteBrake;
533
+ if (!engine || !brake) return;
534
+ await enqueueSyncOperation(async () => {
535
+ if (syncEngine !== engine || deleteBrake !== brake) return;
536
+ const existing = [];
537
+ const missing = [];
538
+ const now = Date.now();
539
+ for (const [suppressedPath, expiresAt] of remoteDeleteSuppressions) if (expiresAt <= now) remoteDeleteSuppressions.delete(suppressedPath);
540
+ for (const candidate of paths) {
541
+ let path;
542
+ try {
543
+ path = validatePrivateRelativePath(candidate);
544
+ } catch {
545
+ log.warn("Realtime sync ignored an invalid workspace path");
546
+ continue;
547
+ }
548
+ if (existsSync(join(workspacePath, path))) {
549
+ remoteDeleteSuppressions.delete(path);
550
+ existing.push(path);
551
+ } else if ((remoteDeleteSuppressions.get(path) ?? 0) > now) remoteDeleteSuppressions.delete(path);
552
+ else missing.push(path);
553
+ }
554
+ log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
555
+ if (existing.length > 0) try {
556
+ lastSyncResult = await engine.push(existing, { quiet: true });
557
+ } catch (err) {
558
+ log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
559
+ }
560
+ if (missing.length === 0) return;
561
+ const artifactDeletes = missing.filter((path) => isRecoveryArtifact(path));
562
+ const regularDeletes = missing.filter((path) => !isRecoveryArtifact(path));
563
+ const manifest = await readManifest(workspacePath);
564
+ const manifestSize = Object.keys(manifest.files).length;
565
+ let toDelete = missing;
566
+ if (!brake.check(regularDeletes.length, manifestSize)) {
567
+ log.error(`Sync delete brake tripped: ${String(brake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
568
+ toDelete = artifactDeletes;
569
+ }
570
+ if (toDelete.length > 0) try {
571
+ lastSyncResult = await engine.pushDeletes(toDelete, { quiet: true });
572
+ } catch (err) {
573
+ log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
574
+ }
575
+ });
576
+ }
322
577
  const SCHEDULE_INTERVALS_MS = {
323
578
  hourly: 3600 * 1e3,
324
579
  daily: 1440 * 60 * 1e3,
@@ -346,76 +601,31 @@ function setupSchedule(schedule, log) {
346
601
  scheduledInterval = setInterval(() => {
347
602
  if (!syncEngine) return;
348
603
  const engine = syncEngine;
349
- (async () => {
350
- try {
351
- log.info(`Scheduled sync (${schedule}) starting...`);
352
- lastSyncResult = await engine.fullSync({ quiet: true });
353
- log.info(`Scheduled sync complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
354
- } catch (err) {
355
- log.error(`Scheduled sync failed: ${err instanceof Error ? err.message : String(err)}`);
356
- }
357
- })();
604
+ log.info(`Scheduled sync (${schedule}) starting...`);
605
+ runFullSync(engine, log, "Scheduled sync");
358
606
  }, intervalMs);
359
607
  scheduledInterval.unref();
360
608
  }
361
- async function connectToDaemon(socketPath, log) {
362
- try {
363
- const ipc = new (await (import("@alfe.ai/openclaw"))).IPCClient(socketPath, log);
364
- ipc.on("connected", () => {
365
- (async () => {
366
- log.info("Connected to Alfe daemon — registering sync capabilities...");
367
- const response = await ipc.request("capability.register", {
368
- plugin: "@alfe.ai/openclaw-sync",
369
- capabilities: [...SYNC_CAPABILITIES]
370
- });
371
- if (response.ok) log.info("Sync capabilities registered with daemon");
372
- else log.warn(`Failed to register sync capabilities: ${response.error?.message ?? "unknown"}`);
373
- })();
374
- });
375
- ipc.on("disconnected", (...args) => {
376
- const reason = typeof args[0] === "string" ? args[0] : String(args[0]);
377
- log.warn(`Disconnected from Alfe daemon: ${reason}`);
378
- });
379
- ipc.on("message", (...args) => {
380
- const msg = args[0];
381
- if (msg?.type === "SYNC_NOW" || msg?.command === "SYNC_NOW") {
382
- log.info("Received SYNC_NOW command — triggering immediate sync...");
383
- if (syncEngine) {
384
- const engine = syncEngine;
385
- (async () => {
386
- try {
387
- lastSyncResult = await engine.fullSync({ quiet: true });
388
- log.info(`SYNC_NOW complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
389
- } catch (err) {
390
- log.error(`SYNC_NOW failed: ${err instanceof Error ? err.message : String(err)}`);
391
- }
392
- })();
393
- }
394
- }
395
- if (msg?.type === "SHARED_SCOPES") {
396
- const scopes = msg.scopes;
397
- const engine = sharedSyncEngine;
398
- if (scopes && engine) {
399
- log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
400
- (async () => {
401
- try {
402
- await engine.updateScopes(scopes);
403
- } catch (err) {
404
- log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
405
- }
406
- })();
609
+ function handleDaemonMessage(msg, log) {
610
+ if (msg.type === "SYNC_NOW" || msg.command === "SYNC_NOW") {
611
+ log.info("Received SYNC_NOW command triggering immediate sync...");
612
+ if (syncEngine) runFullSync(syncEngine, log, "SYNC_NOW");
613
+ }
614
+ if (msg.type === "SHARED_SCOPES") {
615
+ const scopes = parseSharedScopes(msg.scopes);
616
+ const engine = sharedSyncEngine;
617
+ if (!scopes) log.warn("Ignored invalid SHARED_SCOPES payload from daemon");
618
+ else if (engine) {
619
+ log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
620
+ enqueueSyncOperation(async () => {
621
+ if (sharedSyncEngine !== engine) return;
622
+ try {
623
+ await engine.updateScopes(scopes);
624
+ } catch (err) {
625
+ log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
407
626
  }
408
- }
409
- });
410
- ipc.on("error", (...args) => {
411
- const err = args[0];
412
- log.debug(`Daemon IPC error: ${err instanceof Error ? err.message : String(err)}`);
413
- });
414
- ipc.start();
415
- return ipc;
416
- } catch {
417
- log.info("Alfe daemon not available — Sync plugin running standalone");
418
- return null;
627
+ });
628
+ }
419
629
  }
420
630
  }
421
631
  function clearSyncRelayReconnect() {
@@ -450,6 +660,7 @@ async function processPendingNotifications(log) {
450
660
  for (const [filePath, info] of privateEntries) if (info.eventType === "deleted") toDelete.push(filePath);
451
661
  else toPull.push(filePath);
452
662
  for (const filePath of toDelete) try {
663
+ remoteDeleteSuppressions.set(filePath, Date.now() + REMOTE_DELETE_SUPPRESSION_MS);
453
664
  await engine.removeLocalFile(filePath, { quiet: true });
454
665
  log.debug(`Sync relay: deleted ${filePath}`);
455
666
  } catch (err) {
@@ -464,11 +675,20 @@ async function processPendingNotifications(log) {
464
675
  }
465
676
  }
466
677
  }
467
- async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
678
+ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation) {
468
679
  try {
469
680
  const { default: WebSocket } = await import("ws");
470
- const ws = new WebSocket(`${relayUrl}?token=${encodeURIComponent(token)}`);
681
+ if (!syncRelayActive || generation !== syncRelayGeneration) return null;
682
+ const ws = new WebSocket(relayUrl, {
683
+ headers: { Authorization: `Bearer ${token}` },
684
+ handshakeTimeout: SYNC_RELAY_HANDSHAKE_TIMEOUT_MS,
685
+ maxPayload: SYNC_RELAY_MAX_MESSAGE_BYTES
686
+ });
471
687
  ws.on("open", () => {
688
+ if (!syncRelayActive || generation !== syncRelayGeneration) {
689
+ ws.close(1e3, "Stale sync relay connection");
690
+ return;
691
+ }
472
692
  log.info("Connected to Sync Relay");
473
693
  syncRelayReconnectAttempt = 0;
474
694
  ws.send(JSON.stringify({
@@ -477,34 +697,47 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
477
697
  }));
478
698
  });
479
699
  ws.on("message", (data) => {
480
- let message;
700
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
701
+ const raw = data.toString();
702
+ if (Buffer.byteLength(raw) > SYNC_RELAY_MAX_MESSAGE_BYTES) {
703
+ ws.close(1009, "Sync relay message too large");
704
+ return;
705
+ }
706
+ let value;
481
707
  try {
482
- message = JSON.parse(data.toString());
708
+ value = JSON.parse(raw);
483
709
  } catch {
484
710
  return;
485
711
  }
712
+ const message = parseRelayMessage(value);
713
+ if (!message) return;
486
714
  switch (message.type) {
487
715
  case "SUBSCRIBE_ACK":
716
+ if (message.agentId !== agentIdForSubscribe) return;
488
717
  if (message.status === "ok") {
489
- log.info(`Subscribed to sync notifications for agent ${message.agentId ?? agentIdForSubscribe}`);
718
+ log.info(`Subscribed to sync notifications for agent ${message.agentId}`);
490
719
  const sharedEngine = sharedSyncEngine;
491
- if (sharedEngine) (async () => {
720
+ if (sharedEngine) enqueueSyncOperation(async () => {
721
+ if (sharedSyncEngine !== sharedEngine) return;
492
722
  try {
493
723
  await sharedEngine.fullSync();
494
724
  log.info("Shared sync: reconnect full sync complete");
495
725
  } catch (err) {
496
726
  log.error(`Shared sync: reconnect full sync failed: ${err instanceof Error ? err.message : String(err)}`);
497
727
  }
498
- })();
728
+ });
499
729
  } else log.warn(`Sync relay subscribe failed: ${message.message ?? "unknown"}`);
500
730
  break;
501
731
  case "FILE_CHANGED":
502
- if (message.filePath) syncRelayPendingPaths.set(message.filePath, {
732
+ if (message.agentId !== agentIdForSubscribe) return;
733
+ syncRelayPendingPaths.set(message.filePath, {
503
734
  etag: message.etag,
504
- eventType: message.eventType === "deleted" ? "deleted" : "created"
735
+ eventType: message.eventType
505
736
  });
506
737
  clearSyncRelayDebounce();
507
- syncRelayDebounceTimer = setTimeout(() => void processPendingNotifications(log), SYNC_RELAY_DEBOUNCE_MS);
738
+ syncRelayDebounceTimer = setTimeout(() => {
739
+ enqueueSyncOperation(() => processPendingNotifications(log));
740
+ }, SYNC_RELAY_DEBOUNCE_MS);
508
741
  syncRelayDebounceTimer.unref();
509
742
  break;
510
743
  case "PING":
@@ -515,9 +748,10 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
515
748
  }
516
749
  });
517
750
  ws.on("close", (code) => {
751
+ if (syncRelayWs === ws) syncRelayWs = null;
752
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
518
753
  log.info(`Sync Relay disconnected (code=${String(code)})`);
519
- syncRelayWs = null;
520
- scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
754
+ scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
521
755
  });
522
756
  ws.on("error", (err) => {
523
757
  log.debug(`Sync Relay error: ${err.message}`);
@@ -525,40 +759,43 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
525
759
  return ws;
526
760
  } catch (err) {
527
761
  log.debug(`Failed to connect to Sync Relay: ${err instanceof Error ? err.message : String(err)}`);
528
- scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
762
+ if (syncRelayActive && generation === syncRelayGeneration) scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
529
763
  return null;
530
764
  }
531
765
  }
532
- function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log) {
766
+ function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation) {
767
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
533
768
  clearSyncRelayReconnect();
534
769
  const delay = Math.min(SYNC_RELAY_RECONNECT_BASE_MS * Math.pow(2, syncRelayReconnectAttempt), SYNC_RELAY_RECONNECT_MAX_MS);
535
770
  syncRelayReconnectAttempt++;
536
771
  log.debug(`Reconnecting to Sync Relay in ${String(delay)}ms (attempt ${String(syncRelayReconnectAttempt)})`);
537
772
  syncRelayReconnectTimer = setTimeout(() => {
538
773
  (async () => {
539
- syncRelayWs = await connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log);
774
+ const socket = await connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation);
775
+ if (syncRelayActive && generation === syncRelayGeneration) syncRelayWs = socket;
776
+ else if (socket) socket.close(1e3, "Stale sync relay connection");
540
777
  })();
541
778
  }, delay);
542
779
  syncRelayReconnectTimer.unref();
543
780
  }
544
781
  function disconnectSyncRelay() {
782
+ syncRelayActive = false;
783
+ syncRelayGeneration++;
545
784
  clearSyncRelayReconnect();
546
785
  clearSyncRelayDebounce();
547
786
  syncRelayPendingPaths.clear();
787
+ remoteDeleteSuppressions.clear();
548
788
  if (syncRelayWs) {
549
789
  try {
550
- syncRelayWs.send(JSON.stringify({ type: "UNSUBSCRIBE" }));
790
+ if (agentId) syncRelayWs.send(JSON.stringify({
791
+ type: "UNSUBSCRIBE",
792
+ agentId
793
+ }));
551
794
  syncRelayWs.close(1e3, "Plugin deactivating");
552
795
  } catch {}
553
796
  syncRelayWs = null;
554
797
  }
555
798
  }
556
- function deriveRelayUrl(apiUrl) {
557
- if (apiUrl.includes("dev.alfe.ai")) return "wss://sync.dev.alfe.ai/ws";
558
- if (apiUrl.includes("demo.alfe.ai")) return "wss://sync.demo.alfe.ai/ws";
559
- if (apiUrl.includes("test.alfe.ai")) return "wss://sync.test.alfe.ai/ws";
560
- return "wss://sync.alfe.ai/ws";
561
- }
562
799
  const plugin = {
563
800
  id: "@alfe.ai/openclaw-sync",
564
801
  name: "Alfe Sync Plugin",
@@ -581,120 +818,105 @@ const plugin = {
581
818
  ];
582
819
  const syncSchedule = pluginConfig.syncSchedule ?? "realtime";
583
820
  const socketPath = pluginConfig.socketPath ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH;
584
- const startSyncService = async () => {
585
- if (globalThis.__alfeSyncPluginActivated === true) {
586
- log.debug("Alfe Sync plugin already activated — skipping duplicate");
587
- return;
588
- }
589
- globalThis.__alfeSyncPluginActivated = true;
590
- log.info("Alfe Sync plugin activating...");
591
- log.info(`Sync scope: ${syncScope.join(", ")}`);
592
- log.info(`Sync schedule: ${syncSchedule}`);
593
- log.info(`Workspace: ${workspacePath}`);
594
- if (!configExists()) {
595
- log.info("Sync skipped — no Alfe config found. Run `alfe login` to enable.");
596
- return;
597
- }
598
- let syncCfg;
599
- try {
600
- syncCfg = resolveConfig();
601
- } catch (err) {
602
- log.warn(`Sync skipped — failed to resolve credentials from ~/.alfe/config.toml: ${err instanceof Error ? err.message : String(err)}`);
603
- return;
604
- }
605
- client = new AgentApiClient({
606
- apiKey: syncCfg.apiKey,
607
- apiUrl: syncCfg.apiUrl
608
- });
609
- syncEngine = createSyncEngine({
610
- workspacePath,
611
- client,
612
- runtime
613
- });
614
- log.info("Sync engine initialized");
615
- if (syncSchedule === "realtime") {
821
+ const startSyncService = () => {
822
+ guardedStart(SYNC_ACTIVATION_KEY, log, async () => {
823
+ log.info("Alfe Sync plugin activating...");
824
+ log.info(`Sync scope: ${syncScope.join(", ")}`);
825
+ log.info(`Sync schedule: ${syncSchedule}`);
826
+ log.info(`Workspace: ${workspacePath}`);
827
+ if (!configExists()) {
828
+ log.info("Sync skipped — no Alfe config found. Run `alfe login` to enable.");
829
+ resetActivation(SYNC_ACTIVATION_KEY);
830
+ return;
831
+ }
832
+ let syncCfg;
616
833
  try {
617
- lastSyncResult = await syncEngine.firstRunReconcile({ quiet: true });
618
- if (lastSyncResult.conflicts > 0) log.info(`Initial workspace reconcile complete — ${String(lastSyncResult.conflicts)} diverged local file(s) handled (see RECOVERY-*.md in the agent workspace)`);
619
- else log.info("Initial workspace reconcile complete");
834
+ syncCfg = resolveConfig();
620
835
  } catch (err) {
621
- log.warn(`Initial workspace reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
836
+ log.warn(`Sync skipped failed to resolve credentials from ~/.alfe/config.toml: ${err instanceof Error ? err.message : String(err)}`);
837
+ resetActivation(SYNC_ACTIVATION_KEY);
838
+ return;
622
839
  }
623
- syncEngine.pruneIgnored({ quiet: true }).then((pruned) => {
624
- if (pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
625
- }).catch((err) => {
626
- log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
840
+ client = new AgentApiClient({
841
+ apiKey: syncCfg.apiKey,
842
+ apiUrl: syncCfg.apiUrl
627
843
  });
628
- deleteBrake = createDeleteBrake();
629
- try {
630
- stopWatcher = await startWatcher({
631
- workspacePath,
632
- runtime,
633
- debounceMs: 2e3,
634
- onChanges: async (paths) => {
635
- if (!syncEngine) return;
636
- const existing = [];
637
- const missing = [];
638
- for (const p of paths) if (existsSync(join(workspacePath, p))) existing.push(p);
639
- else missing.push(p);
640
- log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
641
- if (existing.length > 0) try {
642
- lastSyncResult = await syncEngine.push(existing, { quiet: true });
643
- } catch (err) {
644
- log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
645
- }
646
- if (missing.length > 0) {
647
- const artifactDeletes = missing.filter((p) => isRecoveryArtifact(p));
648
- const regularDeletes = missing.filter((p) => !isRecoveryArtifact(p));
649
- const manifest = await readManifest(workspacePath);
650
- const manifestSize = Object.keys(manifest.files).length;
651
- let toDelete = missing;
652
- if (deleteBrake && !deleteBrake.check(regularDeletes.length, manifestSize)) {
653
- log.error(`Sync delete brake tripped: ${String(deleteBrake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
654
- toDelete = artifactDeletes;
655
- }
656
- if (toDelete.length > 0) try {
657
- lastSyncResult = await syncEngine.pushDeletes(toDelete, { quiet: true });
658
- } catch (err) {
659
- log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
660
- }
661
- }
662
- }
844
+ syncEngine = createSyncEngine({
845
+ workspacePath,
846
+ client,
847
+ runtime
848
+ });
849
+ log.info("Sync engine initialized");
850
+ if (syncSchedule === "realtime") {
851
+ try {
852
+ lastSyncResult = await syncEngine.firstRunReconcile({ quiet: true });
853
+ if (lastSyncResult.conflicts > 0) log.info(`Initial workspace reconcile complete — ${String(lastSyncResult.conflicts)} diverged local file(s) handled (see RECOVERY-*.md in the agent workspace)`);
854
+ else log.info("Initial workspace reconcile complete");
855
+ } catch (err) {
856
+ log.warn(`Initial workspace reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
857
+ }
858
+ const engineForPrune = syncEngine;
859
+ enqueueSyncOperation(async () => {
860
+ if (syncEngine !== engineForPrune) return null;
861
+ return engineForPrune.pruneIgnored({ quiet: true });
862
+ }).then((pruned) => {
863
+ if (pruned && pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
864
+ }).catch((err) => {
865
+ log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
663
866
  });
664
- log.info("File watcher started for realtime sync");
665
- } catch (err) {
666
- log.warn(`Failed to start file watcher: ${err instanceof Error ? err.message : String(err)}`);
667
- }
668
- } else setupSchedule(syncSchedule, log);
669
- daemonIpcClient = await connectToDaemon(socketPath, log);
670
- let registered = null;
671
- try {
672
- registered = (await client.syncRegister()).agent;
673
- agentId = registered.agentId;
674
- } catch (err) {
675
- log.warn(`Sync register failed: ${err instanceof Error ? err.message : String(err)}`);
676
- }
677
- if (registered) {
867
+ deleteBrake = createDeleteBrake();
868
+ try {
869
+ stopWatcher = await startWatcher({
870
+ workspacePath,
871
+ runtime,
872
+ debounceMs: 2e3,
873
+ onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
874
+ });
875
+ log.info("File watcher started for realtime sync");
876
+ } catch (err) {
877
+ log.warn(`Failed to start file watcher: ${err instanceof Error ? err.message : String(err)}`);
878
+ }
879
+ } else setupSchedule(syncSchedule, log);
880
+ daemonIpcClient = await connectToDaemon(socketPath, log, {
881
+ pluginId: "@alfe.ai/openclaw-sync",
882
+ capabilities: SYNC_CAPABILITIES,
883
+ onMessage: (msg) => {
884
+ handleDaemonMessage(msg, log);
885
+ },
886
+ standaloneNote: "Alfe daemon not available — Sync plugin running standalone"
887
+ });
888
+ let registered = null;
678
889
  try {
679
- syncRelayWs = await connectToSyncRelay(pluginConfig.syncRelayUrl ?? deriveRelayUrl(syncCfg.apiUrl), syncCfg.apiKey, registered.agentId, log);
890
+ registered = (await client.syncRegister()).agent;
891
+ agentId = registered.agentId;
680
892
  } catch (err) {
681
- log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
893
+ log.warn(`Sync register failed: ${err instanceof Error ? err.message : String(err)}`);
682
894
  }
683
- if (pluginConfig.sharedSync !== false) try {
684
- sharedSyncEngine = createSharedSyncEngine({
685
- workspacePath,
686
- client
687
- }, log);
688
- log.info("Shared sync engine created — waiting for SHARED_SCOPES from gateway");
689
- } catch (err) {
690
- log.debug(`Shared sync engine skipped: ${err instanceof Error ? err.message : String(err)}`);
895
+ if (registered) {
896
+ if (pluginConfig.sharedSync !== false) try {
897
+ sharedSyncEngine = createSharedSyncEngine({
898
+ workspacePath,
899
+ client
900
+ }, log);
901
+ log.info("Shared sync engine created — waiting for SHARED_SCOPES from gateway");
902
+ } catch (err) {
903
+ log.debug(`Shared sync engine skipped: ${err instanceof Error ? err.message : String(err)}`);
904
+ }
905
+ try {
906
+ const relayUrl = pluginConfig.syncRelayUrl ?? deriveServiceWsUrl(syncCfg.apiUrl, "sync");
907
+ syncRelayActive = true;
908
+ const relayGeneration = ++syncRelayGeneration;
909
+ syncRelayWs = await connectToSyncRelay(relayUrl, syncCfg.apiKey, registered.agentId, log, relayGeneration);
910
+ } catch (err) {
911
+ log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
912
+ }
691
913
  }
692
- }
914
+ });
693
915
  };
694
916
  const stopSyncService = async () => {
695
- globalThis.__alfeSyncPluginActivated = false;
696
917
  clearSchedule();
697
918
  disconnectSyncRelay();
919
+ deleteBrake = null;
698
920
  if (stopWatcher) {
699
921
  try {
700
922
  await stopWatcher();
@@ -704,7 +926,6 @@ const plugin = {
704
926
  }
705
927
  stopWatcher = null;
706
928
  }
707
- deleteBrake = null;
708
929
  if (daemonIpcClient) {
709
930
  try {
710
931
  daemonIpcClient.stop();
@@ -720,6 +941,7 @@ const plugin = {
720
941
  sharedSyncEngine = null;
721
942
  lastSyncResult = null;
722
943
  currentConfig = {};
944
+ resetActivation(SYNC_ACTIVATION_KEY);
723
945
  log.info("Alfe Sync plugin deactivated");
724
946
  };
725
947
  if (typeof api.registerGatewayMethod === "function") {
@@ -729,10 +951,13 @@ const plugin = {
729
951
  error: "Sync engine not initialized — run `alfe login`"
730
952
  };
731
953
  try {
732
- lastSyncResult = await syncEngine.fullSync({ quiet: true });
733
- return {
954
+ const result = await runFullSync(syncEngine, log, "sync.now");
955
+ return result ? {
734
956
  ok: true,
735
- result: lastSyncResult
957
+ result
958
+ } : {
959
+ ok: false,
960
+ error: "Sync did not complete"
736
961
  };
737
962
  } catch (err) {
738
963
  return {
@@ -746,7 +971,7 @@ const plugin = {
746
971
  ok: true,
747
972
  initialized: !!syncEngine,
748
973
  agentId,
749
- schedule: currentConfig.syncSchedule ?? "daily",
974
+ schedule: currentConfig.syncSchedule ?? "realtime",
750
975
  scope: currentConfig.syncScope ?? [
751
976
  "config",
752
977
  "conversations",
@@ -759,17 +984,19 @@ const plugin = {
759
984
  }
760
985
  api.registerService({
761
986
  id: "alfe-sync-engine",
762
- start: () => startSyncService(),
987
+ start: () => {
988
+ startSyncService();
989
+ },
763
990
  stop: () => stopSyncService()
764
991
  });
765
992
  log.info("Alfe Sync plugin activated");
766
993
  },
767
994
  async deactivate(api) {
768
- globalThis.__alfeSyncPluginActivated = false;
769
995
  const log = api.logger;
770
996
  log.info("Alfe Sync plugin deactivating...");
771
997
  clearSchedule();
772
998
  disconnectSyncRelay();
999
+ deleteBrake = null;
773
1000
  if (stopWatcher) {
774
1001
  try {
775
1002
  await stopWatcher();
@@ -794,6 +1021,7 @@ const plugin = {
794
1021
  sharedSyncEngine = null;
795
1022
  lastSyncResult = null;
796
1023
  currentConfig = {};
1024
+ resetActivation(SYNC_ACTIVATION_KEY);
797
1025
  log.info("Alfe Sync plugin deactivated");
798
1026
  },
799
1027
  async configure(api, config) {
@@ -805,22 +1033,28 @@ const plugin = {
805
1033
  };
806
1034
  if (config.syncSchedule) {
807
1035
  if (config.syncSchedule === "realtime" && syncEngine && !stopWatcher) {
1036
+ clearSchedule();
1037
+ const engine = syncEngine;
1038
+ try {
1039
+ const reconciled = await enqueueSyncOperation(async () => {
1040
+ if (syncEngine !== engine) return null;
1041
+ return engine.firstRunReconcile({ quiet: true });
1042
+ });
1043
+ if (reconciled) lastSyncResult = reconciled;
1044
+ } catch (err) {
1045
+ log.warn(`Realtime reconfigure reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1046
+ }
1047
+ const workspacePath = currentConfig.workspacePath ?? syncEngine.workspacePath;
1048
+ deleteBrake = createDeleteBrake();
808
1049
  stopWatcher = await startWatcher({
809
- workspacePath: currentConfig.workspacePath ?? syncEngine.workspacePath,
1050
+ workspacePath,
810
1051
  runtime: syncEngine.runtime,
811
1052
  debounceMs: 2e3,
812
- onChanges: async (paths) => {
813
- if (!syncEngine) return;
814
- try {
815
- lastSyncResult = await syncEngine.push(paths, { quiet: true });
816
- } catch (err) {
817
- log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
818
- }
819
- }
1053
+ onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
820
1054
  });
821
- clearSchedule();
822
1055
  log.info("Switched to realtime sync");
823
1056
  } else if (config.syncSchedule !== "realtime") {
1057
+ deleteBrake = null;
824
1058
  if (stopWatcher) {
825
1059
  await stopWatcher();
826
1060
  stopWatcher = null;