@echomem/echo-memory-cloud-openclaw-plugin 0.2.0 → 0.2.2

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.
@@ -3,10 +3,15 @@ import { spawn } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs/promises";
5
5
  import fsSync from "node:fs";
6
- import { fileURLToPath } from "node:url";
7
- import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
6
+ import { fileURLToPath } from "node:url";
7
+ import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
8
8
  import { scanFullWorkspace, scanWorkspaceMarkdownFile } from "./openclaw-memory-scan.js";
9
- import { readLastSyncState } from "./state.js";
9
+ import {
10
+ readLastSyncState,
11
+ readLocalUiPresence,
12
+ resolveUiPresencePath,
13
+ writeLocalUiPresence,
14
+ } from "./state.js";
10
15
 
11
16
  const BASE_PORT = 17823;
12
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -15,20 +20,29 @@ const UI_WORKDIR = path.join(__dirname, "local-ui");
15
20
  const UI_DIST_DIR = path.join(__dirname, "local-ui", "dist");
16
21
  const UI_NODE_MODULES_DIR = path.join(UI_WORKDIR, "node_modules");
17
22
 
18
- let _instance = null;
19
- let _bootstrapPromise = null;
20
- let _lastOpenedUrl = null;
23
+ let _instance = null;
24
+ let _bootstrapPromise = null;
25
+ let _lastOpenedUrl = null;
26
+ const BACKEND_SOURCE_LOOKUP_TIMEOUT_MS = 4000;
27
+ const LOCAL_UI_PRESENCE_STALE_MS = 75000;
21
28
 
22
29
  /* ── File Watcher + SSE ────────────────────────────────── */
23
30
 
24
31
  const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", "logs", "completions", "delivery-queue", "browser", "canvas", "cron", "media"]);
25
32
 
26
33
  /** Debounced file-change broadcaster */
27
- function createFileWatcher(workspaceDir) {
28
- const sseClients = new Set();
29
- const watchers = [];
30
- let debounceTimer = null;
31
- const DEBOUNCE_MS = 500;
34
+ function createFileWatcher(workspaceDir) {
35
+ const sseClients = new Set();
36
+ const clientWaiters = new Set();
37
+ const watchers = [];
38
+ let debounceTimer = null;
39
+ const DEBOUNCE_MS = 500;
40
+
41
+ function settleClientWaiters(didConnect) {
42
+ for (const finish of [...clientWaiters]) {
43
+ finish(didConnect);
44
+ }
45
+ }
32
46
 
33
47
  function broadcast(eventData) {
34
48
  const payload = `data: ${JSON.stringify(eventData)}\n\n`;
@@ -66,18 +80,48 @@ function createFileWatcher(workspaceDir) {
66
80
  // Start watching
67
81
  watchRecursive(workspaceDir);
68
82
 
69
- return {
70
- sseClients,
71
- broadcast,
72
- close() {
73
- if (debounceTimer) clearTimeout(debounceTimer);
74
- for (const w of watchers) { try { w.close(); } catch {} }
75
- watchers.length = 0;
76
- for (const res of sseClients) { try { res.end(); } catch {} }
77
- sseClients.clear();
78
- },
79
- };
80
- }
83
+ return {
84
+ sseClients,
85
+ addSseClient(res) {
86
+ sseClients.add(res);
87
+ settleClientWaiters(true);
88
+ },
89
+ removeSseClient(res) {
90
+ sseClients.delete(res);
91
+ },
92
+ waitForClient(timeoutMs = 0) {
93
+ if (sseClients.size > 0) {
94
+ return Promise.resolve(true);
95
+ }
96
+ return new Promise((resolve) => {
97
+ let timer = null;
98
+ const finish = (didConnect) => {
99
+ if (!clientWaiters.delete(finish)) {
100
+ return;
101
+ }
102
+ if (timer) {
103
+ clearTimeout(timer);
104
+ timer = null;
105
+ }
106
+ resolve(didConnect);
107
+ };
108
+ clientWaiters.add(finish);
109
+ if (timeoutMs > 0) {
110
+ timer = setTimeout(() => finish(false), timeoutMs);
111
+ }
112
+ });
113
+ },
114
+ broadcast,
115
+ close() {
116
+ if (debounceTimer) clearTimeout(debounceTimer);
117
+ for (const w of watchers) { try { w.close(); } catch {} }
118
+ watchers.length = 0;
119
+ for (const res of sseClients) { try { res.end(); } catch {} }
120
+ sseClients.clear();
121
+ settleClientWaiters(false);
122
+ },
123
+ };
124
+ }
81
125
 
82
126
  function tryListen(server, port) {
83
127
  return new Promise((resolve) => {
@@ -224,17 +268,17 @@ function getBrowserOpenCommand(url) {
224
268
  return null;
225
269
  }
226
270
 
227
- export async function openUrlInDefaultBrowser(url, opts = {}) {
228
- const { logger, force = false } = opts;
229
- if (!force) {
230
- const skipReason = detectBrowserOpenSkipReason();
231
- if (skipReason) {
232
- return { opened: false, reason: skipReason };
233
- }
234
- }
235
- if (_lastOpenedUrl === url) {
236
- return { opened: false, reason: "already_opened" };
237
- }
271
+ export async function openUrlInDefaultBrowser(url, opts = {}) {
272
+ const { logger, force = false } = opts;
273
+ if (!force) {
274
+ const skipReason = detectBrowserOpenSkipReason();
275
+ if (skipReason) {
276
+ return { opened: false, reason: skipReason };
277
+ }
278
+ }
279
+ if (!force && _lastOpenedUrl === url) {
280
+ return { opened: false, reason: "already_opened" };
281
+ }
238
282
  const command = getBrowserOpenCommand(url);
239
283
  if (!command) {
240
284
  return { opened: false, reason: "unsupported_platform" };
@@ -290,34 +334,143 @@ function readBody(req) {
290
334
  });
291
335
  }
292
336
 
293
- function resolveStoredFilePath(workspaceDir, entry) {
294
- const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
295
- if (!rawPath) {
296
- return null;
297
- }
298
- return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
299
- }
300
-
301
- async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }) {
302
- const [lastState, files] = await Promise.all([
303
- statePath ? readLastSyncState(statePath) : Promise.resolve(null),
304
- scanFullWorkspace(workspaceDir),
305
- ]);
306
-
307
- const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
308
- const resultMap = new Map();
309
- for (const entry of storedResults) {
310
- const storedPath = resolveStoredFilePath(workspaceDir, entry);
311
- if (!storedPath) continue;
312
- resultMap.set(storedPath, entry);
313
- }
337
+ function resolveStoredFilePath(workspaceDir, entry) {
338
+ const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
339
+ if (!rawPath) {
340
+ return null;
341
+ }
342
+ return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
343
+ }
344
+
345
+ function toPathKey(targetPath) {
346
+ const normalized = path.normalize(String(targetPath || ""));
347
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
348
+ }
349
+
350
+ function resolveBackendFilePath(workspaceDir, rawPath) {
351
+ if (!rawPath) {
352
+ return null;
353
+ }
354
+ return path.isAbsolute(rawPath)
355
+ ? path.normalize(rawPath)
356
+ : path.resolve(workspaceDir, String(rawPath));
357
+ }
358
+
359
+ async function readBackendSourceMap(apiClient, workspaceDir) {
360
+ const sources = new Map();
361
+ if (!apiClient) {
362
+ return sources;
363
+ }
364
+
365
+ const addSource = (sourcePath, latestAt = null) => {
366
+ const resolvedPath = resolveBackendFilePath(workspaceDir, sourcePath);
367
+ if (!resolvedPath) {
368
+ return;
369
+ }
370
+ const pathKey = toPathKey(resolvedPath);
371
+ const existing = sources.get(pathKey);
372
+ if (!existing || (latestAt && (!existing.latestAt || latestAt > existing.latestAt))) {
373
+ sources.set(pathKey, {
374
+ latestAt: latestAt || existing?.latestAt || null,
375
+ });
376
+ }
377
+ };
378
+
379
+ try {
380
+ const data = await apiClient.listAllSources();
381
+ for (const source of data?.paths || []) {
382
+ addSource(source?.file_path, source?.latest_at ?? null);
383
+ }
384
+ return sources;
385
+ } catch {
386
+ // Fall through to import-status fallback.
387
+ }
388
+
389
+ try {
390
+ const status = await apiClient.getImportStatus();
391
+ for (const source of status?.recent_sources || []) {
392
+ addSource(source?.file_path, source?.created_at ?? null);
393
+ }
394
+ } catch {
395
+ // Ignore backend status fallback failures in the local UI.
396
+ }
397
+
398
+ return sources;
399
+ }
400
+
401
+ function resolveWithin(timeoutMs, value) {
402
+ return new Promise((resolve) => {
403
+ setTimeout(() => resolve(value), timeoutMs);
404
+ });
405
+ }
406
+
407
+ function getLocalUiPresencePath(syncRunner) {
408
+ try {
409
+ const statePath = syncRunner?.getStatePath?.();
410
+ return statePath ? resolveUiPresencePath(path.dirname(statePath)) : null;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ async function updateLocalUiPresence(syncRunner, payload = {}) {
417
+ const presencePath = getLocalUiPresencePath(syncRunner);
418
+ if (!presencePath) {
419
+ return null;
420
+ }
421
+
422
+ const previous = await readLocalUiPresence(presencePath);
423
+ const next = {
424
+ clientId: payload.clientId || previous?.clientId || null,
425
+ serverInstanceId: payload.serverInstanceId || previous?.serverInstanceId || null,
426
+ active: payload.active !== undefined ? Boolean(payload.active) : true,
427
+ lastSeenAt: payload.lastSeenAt || new Date().toISOString(),
428
+ };
429
+ await writeLocalUiPresence(presencePath, next);
430
+ return next;
431
+ }
432
+
433
+ export async function hasRecentLocalUiPresence(syncRunner, { maxAgeMs = LOCAL_UI_PRESENCE_STALE_MS } = {}) {
434
+ const presencePath = getLocalUiPresencePath(syncRunner);
435
+ if (!presencePath) {
436
+ return false;
437
+ }
438
+
439
+ const presence = await readLocalUiPresence(presencePath);
440
+ if (!presence?.lastSeenAt || presence.active === false) {
441
+ return false;
442
+ }
443
+
444
+ const ageMs = Date.now() - new Date(presence.lastSeenAt).getTime();
445
+ return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= maxAgeMs;
446
+ }
447
+
448
+ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath, apiClient }) {
449
+ const backendSourcesPromise = readBackendSourceMap(apiClient, workspaceDir).catch(() => new Map());
450
+ const [lastState, files, backendSources] = await Promise.all([
451
+ statePath ? readLastSyncState(statePath) : Promise.resolve(null),
452
+ scanFullWorkspace(workspaceDir),
453
+ Promise.race([
454
+ backendSourcesPromise,
455
+ resolveWithin(BACKEND_SOURCE_LOOKUP_TIMEOUT_MS, new Map()),
456
+ ]),
457
+ ]);
458
+
459
+ const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
460
+ const resultMap = new Map();
461
+ for (const entry of storedResults) {
462
+ const storedPath = resolveStoredFilePath(workspaceDir, entry);
463
+ if (!storedPath) continue;
464
+ resultMap.set(toPathKey(storedPath), entry);
465
+ }
314
466
 
315
467
  const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
316
468
  const eligibleAbsolutePaths = new Set();
317
469
  const eligibleRelativePaths = [];
318
470
 
319
471
  const fileStatuses = files.map((f) => {
320
- const absPath = path.resolve(workspaceDir, f.relativePath);
472
+ const absPath = path.resolve(workspaceDir, f.relativePath);
473
+ const pathKey = toPathKey(absPath);
321
474
  const isPrivate =
322
475
  f.relativePath.startsWith("memory/private/") ||
323
476
  f.privacyLevel === "private";
@@ -325,16 +478,22 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
325
478
  Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
326
479
  const isDaily =
327
480
  f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
328
- const stored = resultMap.get(absPath) ?? null;
481
+ const stored = resultMap.get(pathKey) ?? null;
329
482
  const storedStatus = String(stored?.status || "").trim().toLowerCase() || null;
330
483
  const attemptedHash = stored?.contentHash || stored?.content_hash || null;
331
- const successfulHash =
332
- stored?.lastSuccessfulContentHash
333
- || stored?.last_successful_content_hash
334
- || (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
335
- const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
336
- const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
337
- const lastSuccessAt = stored?.lastSuccessAt || stored?.last_success_at || lastState?.finished_at || null;
484
+ const successfulHash =
485
+ stored?.lastSuccessfulContentHash
486
+ || stored?.last_successful_content_hash
487
+ || (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
488
+ const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
489
+ const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
490
+ const backendSource = backendSources.get(pathKey) ?? null;
491
+ const lastSuccessAt =
492
+ stored?.lastSuccessAt
493
+ || stored?.last_success_at
494
+ || backendSource?.latestAt
495
+ || lastState?.finished_at
496
+ || null;
338
497
 
339
498
  if (isPrivate) {
340
499
  return {
@@ -359,14 +518,16 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
359
518
  eligibleAbsolutePaths.add(absPath);
360
519
  eligibleRelativePaths.push(f.relativePath);
361
520
 
362
- let status = "new";
363
- if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
364
- status = "failed";
365
- } else if (successfulHash) {
366
- status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
367
- } else if (storedStatus === "failed") {
368
- status = "modified";
369
- }
521
+ let status = "new";
522
+ if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
523
+ status = "failed";
524
+ } else if (successfulHash) {
525
+ status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
526
+ } else if (backendSource) {
527
+ status = "synced";
528
+ } else if (storedStatus === "failed") {
529
+ status = "modified";
530
+ }
370
531
 
371
532
  return {
372
533
  fileName: f.fileName,
@@ -388,10 +549,10 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
388
549
  };
389
550
  }
390
551
 
391
- function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
392
- const normalizedBase = path.resolve(workspaceDir) + path.sep;
393
- const { apiClient, syncRunner, cfg, fileWatcher, logger } = opts;
394
- const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
552
+ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
553
+ const normalizedBase = path.resolve(workspaceDir) + path.sep;
554
+ const { apiClient, syncRunner, cfg, fileWatcher, logger, serverInstanceId } = opts;
555
+ const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
395
556
 
396
557
  return async function handler(req, res) {
397
558
  setCorsHeaders(res);
@@ -414,22 +575,47 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
414
575
  return;
415
576
  }
416
577
 
417
- // SSE endpoint — push file-change events to the frontend
418
- if (url.pathname === "/api/events") {
419
- res.writeHead(200, {
420
- "Content-Type": "text/event-stream",
421
- "Cache-Control": "no-cache",
422
- "Connection": "keep-alive",
423
- });
424
- res.write(": connected\n\n");
425
- if (fileWatcher) {
426
- fileWatcher.sseClients.add(res);
427
- req.on("close", () => fileWatcher.sseClients.delete(res));
428
- }
429
- return;
430
- }
431
-
432
- if (url.pathname === "/") {
578
+ // SSE endpoint — push file-change events to the frontend
579
+ if (url.pathname === "/api/events") {
580
+ res.writeHead(200, {
581
+ "Content-Type": "text/event-stream",
582
+ "Cache-Control": "no-cache",
583
+ "Connection": "keep-alive",
584
+ });
585
+ res.write("retry: 1000\n");
586
+ res.write(`data: ${JSON.stringify({
587
+ type: "server-connected",
588
+ serverInstanceId,
589
+ connectedAt: new Date().toISOString(),
590
+ })}\n\n`);
591
+ if (fileWatcher) {
592
+ fileWatcher.addSseClient(res);
593
+ req.on("close", () => fileWatcher.removeSseClient(res));
594
+ }
595
+ return;
596
+ }
597
+
598
+ if (url.pathname === "/api/ui-presence" && req.method === "POST") {
599
+ try {
600
+ const bodyResult = await readBody(req);
601
+ if (!bodyResult.ok) {
602
+ sendJson(res, { ok: false, error: "invalid_json" });
603
+ return;
604
+ }
605
+ await updateLocalUiPresence(syncRunner, {
606
+ clientId: typeof bodyResult.body?.clientId === "string" ? bodyResult.body.clientId : null,
607
+ serverInstanceId: typeof bodyResult.body?.serverInstanceId === "string" ? bodyResult.body.serverInstanceId : serverInstanceId,
608
+ active: bodyResult.body?.active,
609
+ });
610
+ sendJson(res, { ok: true });
611
+ } catch (error) {
612
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
613
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
614
+ }
615
+ return;
616
+ }
617
+
618
+ if (url.pathname === "/") {
433
619
  // Always serve the built React app from dist/
434
620
  let content;
435
621
  try {
@@ -506,21 +692,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
506
692
  return;
507
693
  }
508
694
 
509
- if (fileScan.privacyLevel === "private") {
510
- sendJson(res, {
511
- fileName: fileScan.fileName,
512
- blocked: true,
513
- privacyLevel: fileScan.privacyLevel,
514
- privacyAutoUpgraded: fileScan.privacyAutoUpgraded,
515
- hasSensitiveContent: fileScan.hasSensitiveContent,
516
- hasHighRiskSensitiveContent: fileScan.hasHighRiskSensitiveContent,
517
- sensitiveSummary: fileScan.sensitiveSummary,
518
- sensitiveFindings: fileScan.sensitiveFindings,
519
- });
520
- return;
521
- }
522
-
523
- sendJson(res, { fileName: fileScan.fileName, content: fileScan.content });
695
+ sendJson(res, {
696
+ fileName: fileScan.fileName,
697
+ content: fileScan.content,
698
+ privacyLevel: fileScan.privacyLevel,
699
+ privacyAutoUpgraded: fileScan.privacyAutoUpgraded,
700
+ hasSensitiveContent: fileScan.hasSensitiveContent,
701
+ hasHighRiskSensitiveContent: fileScan.hasHighRiskSensitiveContent,
702
+ sensitiveSummary: fileScan.sensitiveSummary,
703
+ sensitiveFindings: fileScan.sensitiveFindings,
704
+ });
524
705
  return;
525
706
  }
526
707
 
@@ -576,11 +757,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
576
757
  typeof body.apiKey === "string" && body.apiKey.trim()
577
758
  ? "false"
578
759
  : "true",
579
- };
580
- const saveResult = saveLocalUiSetup(payload);
581
- if (cfg) {
582
- cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
583
- cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
760
+ };
761
+ const saveResult = saveLocalUiSetup(payload);
762
+ if (saveResult.migratedFrom) {
763
+ logger?.info?.(
764
+ `[echo-memory] Migrated local UI setup from ${saveResult.migratedFrom} to ${saveResult.targetPath}`,
765
+ );
766
+ }
767
+ if (cfg) {
768
+ cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
769
+ cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
584
770
  cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
585
771
  }
586
772
  sendJson(res, {
@@ -632,11 +818,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
632
818
  if (url.pathname === "/api/sync-status") {
633
819
  try {
634
820
  const statePath = syncRunner?.getStatePath() ?? null;
635
- const syncView = await buildWorkspaceSyncView({
636
- workspaceDir,
637
- syncMemoryDir,
638
- statePath,
639
- });
821
+ const syncView = await buildWorkspaceSyncView({
822
+ workspaceDir,
823
+ syncMemoryDir,
824
+ statePath,
825
+ apiClient,
826
+ });
640
827
  sendJson(res, {
641
828
  lastSyncAt: syncView.lastState?.finished_at ?? null,
642
829
  syncedFileCount: syncView.fileStatuses.filter((status) => status.status === 'synced').length,
@@ -723,11 +910,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
723
910
  }
724
911
 
725
912
  const statePath = syncRunner?.getStatePath() ?? null;
726
- const syncView = await buildWorkspaceSyncView({
727
- workspaceDir,
728
- syncMemoryDir,
729
- statePath,
730
- });
913
+ const syncView = await buildWorkspaceSyncView({
914
+ workspaceDir,
915
+ syncMemoryDir,
916
+ statePath,
917
+ apiClient,
918
+ });
731
919
  const statusMap = new Map(syncView.fileStatuses.map((status) => [status.relativePath, status]));
732
920
  const requestedFilterPaths = new Set();
733
921
  const requestedInvalidPaths = [];
@@ -794,15 +982,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
794
982
  };
795
983
  }
796
984
 
797
- export async function startLocalServer(workspaceDir, opts = {}) {
798
- if (_instance) {
799
- return _instance.url;
800
- }
801
-
802
- await ensureLocalUiReady(opts.cfg, opts.logger);
803
- const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
804
- const fileWatcher = createFileWatcher(workspaceDir);
805
- const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
985
+ export async function startLocalServer(workspaceDir, opts = {}) {
986
+ if (_instance) {
987
+ return _instance.url;
988
+ }
989
+
990
+ await ensureLocalUiReady(opts.cfg, opts.logger);
991
+ const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
992
+ const fileWatcher = createFileWatcher(workspaceDir);
993
+ const serverInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
994
+ const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
806
995
  ? opts.syncRunner.onProgress((event) => {
807
996
  const mapPath = (targetPath) => {
808
997
  if (!targetPath) return null;
@@ -832,8 +1021,8 @@ export async function startLocalServer(workspaceDir, opts = {}) {
832
1021
  });
833
1022
  })
834
1023
  : null;
835
- const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
836
- const server = http.createServer(handler);
1024
+ const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher, serverInstanceId });
1025
+ const server = http.createServer(handler);
837
1026
 
838
1027
  let port = null;
839
1028
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -849,11 +1038,15 @@ export async function startLocalServer(workspaceDir, opts = {}) {
849
1038
  fileWatcher.close();
850
1039
  throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
851
1040
  }
852
-
853
- const url = `http://127.0.0.1:${port}`;
854
- _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
855
- return url;
856
- }
1041
+
1042
+ const url = `http://127.0.0.1:${port}`;
1043
+ _instance = { server, url, fileWatcher, unsubscribeSyncProgress, serverInstanceId };
1044
+ return url;
1045
+ }
1046
+
1047
+ export async function waitForLocalUiClient({ timeoutMs = 0 } = {}) {
1048
+ return _instance?.fileWatcher?.waitForClient(timeoutMs) ?? false;
1049
+ }
857
1050
 
858
1051
  export function stopLocalServer() {
859
1052
  if (_instance) {
@@ -0,0 +1 @@
1
+ .card{position:absolute;border-radius:4px;overflow:hidden;box-shadow:0 1px 3px #0000002e;cursor:pointer;display:flex;flex-direction:column;padding:8px 10px;contain:layout style paint;animation:cardFadeIn .5s ease-out both}@keyframes cardFadeIn{0%{opacity:0}to{opacity:1}}.card:hover{box-shadow:0 4px 16px #0000004d;z-index:10}.card-selected{transform:scale(1.08);transform-origin:center center;box-shadow:0 8px 32px #a78bfa59,0 0 0 2px #a78bfa99;z-index:50!important;transition:transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .25s ease}.card-dimmed{opacity:.35;transition:opacity .3s ease}.card-dimmed:hover{opacity:.7}.card-expand-btn{flex-shrink:0;width:20px;height:20px;border:none;border-radius:4px;background:#a78bfa33;color:#a78bfa;font-size:12px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,transform .15s;line-height:1}.card-expand-btn:hover{background:#a78bfa66;transform:scale(1.15)}.card-checkbox{flex-shrink:0;font-size:14px;cursor:pointer;color:#999;line-height:1;transition:color .15s}.card-checkbox-on{color:#a78bfa}.card-lod0{position:absolute;border-radius:3px;contain:strict;pointer-events:none}.card-header{display:flex;align-items:center;gap:6px;flex-shrink:0;margin-bottom:4px}.card-name{font:600 11px/1.3 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.card-warning-toggle{flex-shrink:0;max-width:48%;display:inline-flex;align-items:center;gap:4px;border:1px solid rgba(160,112,48,.35);border-radius:999px;background:#a070301a;color:#8b6128;padding:2px 6px;font:600 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;min-width:0}.card-warning-toggle-high{border-color:#b040406b;background:#b040401a;color:#9c3d3d}.card-warning-toggle__icon{flex-shrink:0}.card-warning-toggle__text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-private-badge{flex-shrink:0;border:1px solid rgba(156,61,61,.35);border-radius:999px;background:#9c3d3d14;color:#9c3d3d;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.card-cluster-badge{flex-shrink:0;border-radius:999px;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.3px;border:1px solid rgba(255,255,255,.18);background:#ffffff1f;color:#d6d0c6;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-cluster-badge-identity{color:#fca5a5;border-color:#fca5a547;background:#fca5a514}.card-cluster-badge-long-term{color:#fdba74;border-color:#fdba7447;background:#fdba7414}.card-cluster-badge-journal{color:#fde68a;border-color:#fde68a47;background:#fde68a14}.card-cluster-badge-goals{color:#d6b388;border-color:#d6b38847;background:#d6b38814}.card-cluster-badge-technical{color:#93c5fd;border-color:#93c5fd47;background:#93c5fd14}.card-cluster-badge-thematic{color:#d8b4fe;border-color:#d8b4fe47;background:#d8b4fe14}.card-cluster-badge-knowledge{color:#86efac;border-color:#86efac47;background:#86efac14}.card-cluster-badge-system{color:#cbd5e1;border-color:#cbd5e147;background:#cbd5e114}.card-warning-panel{margin-bottom:6px;border:1px solid rgba(176,64,64,.2);border-radius:4px;background:#fff8f4eb;padding:6px 8px;font:500 9px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#6b443d;pointer-events:none}.card-warning-panel__header,.card-warning-panel__row{display:flex;align-items:center;justify-content:space-between;gap:8px}.card-warning-panel__header{font-weight:700;margin-bottom:4px}.card-warning-panel__privacy{color:#9c3d3d}.card-warning-panel__row+.card-warning-panel__row{margin-top:2px}.card-content{flex:1;overflow:hidden;font:400 9.5px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.01em;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);opacity:.72}.card-session-log{animation:cardFadeInLog .5s ease-out both}.card-session-log:hover{opacity:.75}@keyframes cardFadeInLog{0%{opacity:0}to{opacity:.5}}.session-badge{font-size:9px;flex-shrink:0}.card-content-log{flex:1;overflow:hidden;font:400 8px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#aaa;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);opacity:.5}.stamp{flex-shrink:0;font:700 7px/1 -apple-system,sans-serif;letter-spacing:.5px;padding:2px 5px;border-radius:1px;pointer-events:none;border:1.5px solid;text-transform:uppercase;transform:rotate(-2deg);opacity:.75}.stamp-new,.stamp-mod{color:#a07030;border-color:#a07030;background:#a0703014}.stamp-synced{color:#9090a0;border-color:#9090a0;background:#9090a00f;opacity:.5}.stamp-local{color:#8d7e67;border-color:#8d7e67;background:#8d7e6714}.stamp-transient{opacity:.92}.stamp-queued{color:#7da2d6;border-color:#7da2d6;background:#7da2d61f}.stamp-syncing{color:#67d5c1;border-color:#67d5c1;background:#67d5c124}.stamp-done{color:#74c98a;border-color:#74c98a;background:#74c98a1f}.stamp-failed{color:#e58a8a;border-color:#e58a8a;background:#e58a8a24}.card-unselectable{opacity:.88}.card-checkbox-disabled{opacity:.35}.stamp-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-8deg);font:800 14px/1 -apple-system,Songti SC,"Noto Serif SC",serif;letter-spacing:2px;color:#b04040;border:2.5px solid #b04040;padding:4px 10px;border-radius:2px;opacity:.55;pointer-events:none;white-space:nowrap;text-transform:uppercase}.stamp-overlay.stamp-sealed{text-shadow:0 0 2px rgba(176,64,64,.3);box-shadow:inset 0 0 4px #b0404026}.card-sealed .card-content{filter:blur(2px);opacity:.4}.minimap{position:absolute;bottom:40px;right:12px;border-radius:6px;overflow:hidden;border:1px solid #2a2a36;box-shadow:0 4px 16px #0006;z-index:80;cursor:pointer;opacity:.85;transition:opacity .15s}.minimap:hover{opacity:1}.minimap-canvas{display:block;width:180px;height:120px}.viewport-root{flex:1;position:relative;overflow:hidden}.viewport{width:100%;height:100%;cursor:grab;-webkit-user-select:none;user-select:none;overflow:hidden}.viewport:active{cursor:grabbing}.canvas{position:absolute;top:0;left:0;transform-origin:0 0;will-change:transform}.section-label{position:absolute;font-size:13px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;opacity:.3;pointer-events:none;white-space:nowrap;display:flex;align-items:baseline;gap:8px}.section-count{font-size:11px;font-weight:500;opacity:.6}.zoom-indicator{position:absolute;bottom:8px;right:8px;font-size:11px;color:#555;background:#0a0a0fcc;padding:3px 8px;border-radius:4px;pointer-events:none;z-index:50}.viewport-controls{position:absolute;right:12px;top:12px;display:flex;flex-direction:column;gap:8px;z-index:60}.viewport-control-pad{display:flex;flex-direction:column;gap:6px}.viewport-control-pad__row{display:flex;gap:6px}.viewport-control{min-width:64px;height:34px;border:1px solid rgba(90,86,78,.24);border-radius:8px;background:#faf8f3eb;color:#3a342d;font:600 12px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;box-shadow:0 8px 20px #2d28201f;transition:transform .12s ease,background .12s ease,border-color .12s ease}.viewport-control:hover{background:#fffcf5fa;border-color:#7a623066;transform:translateY(-1px)}.viewport-control:active{transform:translateY(0)}.viewport-control-fit{min-width:70px}.reading-panel-wrapper{flex:1;display:flex;overflow:hidden;background:#faf9f6;animation:rpFadeIn .3s ease-out}@keyframes rpFadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.reading-panel{flex:1;display:flex;flex-direction:column;max-width:800px;margin:0 auto;width:100%}.rp-header{flex-shrink:0;padding:24px 32px 16px;border-bottom:1px solid #e0ddd8;display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px}.rp-back{flex-shrink:0;width:32px;height:32px;border:none;border-radius:6px;background:#0000000f;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s;margin-right:8px}.rp-back:hover{background:#0000001f;color:#333}.rp-title{font:700 24px/1.2 Georgia,"Noto Serif",serif;color:#2a2520;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rp-path{font:400 11px/1 -apple-system,sans-serif;color:#a09888;word-break:break-all;width:100%;padding-left:40px}.rp-body{flex:1;overflow-y:auto;padding:32px 32px 64px;font:400 16px/1.8 Georgia,"Noto Serif",serif;color:#3a3530;-webkit-font-smoothing:antialiased}.rp-h1{font-size:28px;font-weight:800;margin:36px 0 14px;color:#1a1510;line-height:1.3}.rp-h2{font-size:22px;font-weight:700;margin:32px 0 12px;color:#2a2520;line-height:1.3;border-bottom:1px solid #e8e4df;padding-bottom:8px}.rp-h3{font-size:18px;font-weight:700;margin:24px 0 8px;color:#3a3530;line-height:1.3}.rp-h4{font-size:16px;font-weight:700;margin:20px 0 6px;color:#4a4540;line-height:1.4}.rp-h5{font-size:15px;font-weight:700;margin:16px 0 4px;color:#5a5550}.rp-h6{font-size:13px;font-weight:600;margin:14px 0 4px;color:#6a6560;text-transform:uppercase;letter-spacing:.5px}.rp-p{margin:0 0 4px}.rp-spacer{height:14px}.rp-list{margin:4px 0 8px 24px;padding:0}.rp-list li{margin:4px 0}.rp-quote{margin:14px 0;padding:10px 20px;border-left:3px solid #c8b898;background:#c8b89814;color:#6a6050;font-style:italic}.rp-code-block{margin:14px 0;padding:16px 18px;background:#f0ede8;border-radius:6px;overflow-x:auto;font:400 13.5px/1.55 SF Mono,Fira Code,Consolas,monospace;color:#4a4540}.rp-inline-code{background:#ede9e3;padding:2px 6px;border-radius:3px;font:400 13.5px/1 SF Mono,Fira Code,Consolas,monospace;color:#6a5540}.rp-body a{color:#7c6a4f;text-decoration:underline;text-decoration-color:#7c6a4f4d;transition:text-decoration-color .15s}.rp-body a:hover{text-decoration-color:#7c6a4fcc}.rp-hr{border:none;border-top:1px solid #ddd8d0;margin:28px 0}.rp-body strong{font-weight:700;color:#2a2520}.rp-body em{font-style:italic}.rp-body del{text-decoration:line-through;opacity:.5}.rp-warning{max-width:640px;margin:0 auto 24px;border:1px solid #d7c49a;border-radius:10px;background:#fff9eb;padding:24px 26px;box-shadow:0 10px 30px #82641e14}.rp-warning__title{font:700 22px/1.25 Georgia,"Noto Serif",serif;color:#7a5a1f;margin-bottom:10px}.rp-warning__copy{margin:0 0 14px}.rp-warning__privacy{font:600 13px/1.4 -apple-system,sans-serif;color:#8a6421;margin-bottom:12px}.rp-warning__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;border-top:1px solid rgba(138,100,33,.14);font:600 13px/1.4 -apple-system,sans-serif}.rp-body::-webkit-scrollbar{width:6px}.rp-body::-webkit-scrollbar-track{background:transparent}.rp-body::-webkit-scrollbar-thumb{background:#d0ccc4;border-radius:3px}.rp-body::-webkit-scrollbar-thumb:hover{background:#b0aaa0}:root{--canvas-bg: #1a1a22;--chrome-bg: rgba(10, 10, 15, .92);--chrome-border: #2a2a36;--accent: #a78bfa;--setup-width: 340px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--canvas-bg);color:#e0e0f0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;height:100vh;overflow:hidden}#root{display:flex;flex-direction:column;height:100vh}.setup-sidebar{position:fixed;top:44px;left:0;bottom:32px;width:22px;z-index:120;transition:width .22s ease}.setup-sidebar:hover{width:var(--setup-width)}.setup-sidebar__rail{position:absolute;inset:0 auto 0 0;width:22px;display:flex;align-items:center;justify-content:center;writing-mode:vertical-rl;transform:rotate(180deg);background:#16161feb;border-right:1px solid var(--chrome-border);color:#9389c9;font-size:10px;letter-spacing:.18em;text-transform:uppercase}.setup-sidebar__panel{position:absolute;inset:0 auto 0 22px;width:calc(var(--setup-width) - 22px);padding:18px 16px 16px;background:linear-gradient(180deg,#101019fa,#0b0b12fa),radial-gradient(circle at top,rgba(167,139,250,.18),transparent 42%);border-right:1px solid var(--chrome-border);overflow-y:auto;transform:translate(calc(-100% - 22px));transition:transform .22s ease}.setup-sidebar:hover .setup-sidebar__panel{transform:translate(0)}.setup-sidebar__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.setup-sidebar__header h2{font-size:16px;color:#f3efff;margin-bottom:4px}.setup-sidebar__header p{color:#aaa2c8;font-size:12px;line-height:1.45}.setup-pill{border:1px solid #50476a;border-radius:999px;color:#d0c7f4;font-size:10px;padding:5px 9px;white-space:nowrap}.setup-pill--ok{border-color:#2d6f4b;color:#9ff0be}.setup-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin-bottom:12px;box-shadow:0 10px 30px #0000002e}.setup-card__title{color:#f1eaff;font-size:12px;font-weight:700;margin-bottom:8px;text-transform:uppercase;letter-spacing:.08em}.setup-copy{color:#c4bedc;font-size:12px;line-height:1.55}.setup-copy code{color:#fff;font-size:11px;word-break:break-all}.setup-steps{margin-left:16px;margin-bottom:8px;color:#d7d0ee;font-size:12px;line-height:1.6}.setup-field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}.setup-field span{color:#efe9ff;font-size:11px;font-weight:600}.setup-field input{background:#09090fbf;border:1px solid #3a3450;border-radius:10px;color:#f4f0ff;padding:10px 12px;font-size:12px;outline:none}.setup-field input:focus{border-color:var(--accent)}.setup-field small{color:#9087af;font-size:10px}.setup-save-btn{width:100%;border:1px solid #3d2f69;border-radius:10px;background:linear-gradient(135deg,#34255f,#5b43a0);color:#fff;font-weight:700;font-size:12px;padding:10px 12px;cursor:pointer}.setup-save-btn:disabled{opacity:.65;cursor:default}.setup-msg{margin-top:10px;font-size:11px;line-height:1.4}.setup-msg--ok{color:#8df0b2}.setup-msg--error{color:#ff9898}.hdr{height:44px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:12px;z-index:100;flex-shrink:0}.hdr-icon{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8d84b8}.hdr-title{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.3px}.hdr-title-system{color:#9ca3af}.hdr-back{font-size:12px;color:#888;cursor:pointer;padding:3px 8px;border-radius:4px;transition:background .15s,color .15s}.hdr-back:hover{background:#ffffff14;color:var(--accent)}.hdr-search{background:#16161f;border:1px solid var(--chrome-border);border-radius:6px;color:#8f8fb0;font-size:12px;padding:5px 10px;width:180px;outline:none}.hdr-spacer{flex:1}.hdr-meta{font-size:11px;color:#555}.hdr-meta b{color:#777;font-weight:500}.hdr-conn{font-size:11px}.conn-ok{color:#4ade80}.conn-off{color:#f59e0b}.ftr{height:32px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-top:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:16px;font-size:11px;color:#555;z-index:100;flex-shrink:0}.ftr b{color:#777;font-weight:500}.ftr-spacer{flex:1}.sync-btn{background:#2d1b69;color:var(--accent);border:1px solid #3b2a7a;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer}.sync-btn:hover{background:#3b2a7a}.sync-btn:disabled{opacity:.4;cursor:default}.ftr-system{color:#666;font-size:10px;cursor:pointer;padding:2px 8px;border-radius:4px;background:#ffffff0a;border:1px solid #2a2a36}.ftr-system:hover{background:#ffffff14;color:#888}.ftr-select-toggle{background:none;border:1px solid #333;border-radius:4px;color:#888;font-size:11px;padding:2px 8px;cursor:pointer}.ftr-select-toggle:hover{background:#ffffff0f;color:#bbb}.explore-btn{background:linear-gradient(135deg,#6d28d9,#a855f7);color:#fff;border:none;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer;text-decoration:none}.explore-btn:hover{opacity:.88;transform:translateY(-1px)}.explore-btn[aria-disabled=true]{opacity:.5;cursor:default;transform:none}.sync-result{color:#4ade80}.sync-error{color:#f87171}.sync-progress-dock{height:56px;background:#0d0d13f2;border-top:1px solid var(--chrome-border);border-bottom:1px solid var(--chrome-border);padding:10px 16px 8px;display:flex;flex-direction:column;gap:6px;z-index:100;flex-shrink:0}.sync-progress-top{display:flex;align-items:center;gap:12px;font-size:11px;color:#b8b8c8;flex-wrap:wrap}.sync-progress-title{color:#f0eff8;font-weight:700}.sync-progress-meta{color:#9a98ad}.sync-progress-track{width:100%;height:8px;border-radius:999px;background:#ffffff14;overflow:hidden}.sync-progress-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#4d6cff,#68dfc4);transition:width .18s ease}.sync-progress-detail{color:#f3b2b2;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty-state{flex:1;display:flex;align-items:center;justify-content:center;color:#666;font-size:14px}.selection-copy{color:#a78bfa}@media(max-width:900px){.setup-sidebar{width:18px}.setup-sidebar:hover{width:min(92vw,var(--setup-width))}.hdr-search{width:120px}.hdr-meta{display:none}}