@echomem/echo-memory-cloud-openclaw-plugin 0.2.0 → 0.2.1
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/README.md +11 -2
- package/clawdbot.plugin.json +2 -2
- package/index.js +29 -6
- package/lib/config.js +31 -8
- package/lib/local-server.js +107 -55
- package/lib/local-ui/dist/assets/index-Cnb-zSN2.js +54 -0
- package/lib/local-ui/dist/index.html +1 -1
- package/lib/onboarding.js +2 -1
- package/moltbot.plugin.json +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/lib/local-ui/dist/assets/index-CJrdHn7-.js +0 -54
package/README.md
CHANGED
|
@@ -59,8 +59,11 @@ Path resolution order for `memoryDir`:
|
|
|
59
59
|
Supported runtime `.env` locations:
|
|
60
60
|
|
|
61
61
|
- `~/.openclaw/.env`
|
|
62
|
-
|
|
63
|
-
-
|
|
62
|
+
|
|
63
|
+
Legacy one-release migration bridge:
|
|
64
|
+
|
|
65
|
+
- if `~/.openclaw/.env` is missing, EchoMemory can still read `~/.moltbot/.env` or `~/.clawdbot/.env`
|
|
66
|
+
- new saves and updated setup should go to `~/.openclaw/.env`
|
|
64
67
|
|
|
65
68
|
Supported environment variables:
|
|
66
69
|
|
|
@@ -132,6 +135,11 @@ Older configs may still include `baseUrl` or `webBaseUrl`. Those keys are deprec
|
|
|
132
135
|
|
|
133
136
|
### Install from a local path
|
|
134
137
|
|
|
138
|
+
Version note:
|
|
139
|
+
|
|
140
|
+
- on OpenClaw `2026.3.22+`, avoid bare plugin names during install because plugin source precedence changed
|
|
141
|
+
- use an exact local path, `--link`, or the exact scoped npm package
|
|
142
|
+
|
|
135
143
|
On Windows, quote the path if your username or folders contain spaces:
|
|
136
144
|
|
|
137
145
|
```powershell
|
|
@@ -229,6 +237,7 @@ The plugin starts a localhost workspace UI during gateway startup and can auto-o
|
|
|
229
237
|
|
|
230
238
|
- first run can automatically trigger `npm install` and `npm run build` under `lib/local-ui`
|
|
231
239
|
- browser auto-open is skipped automatically for SSH, CI, and headless Linux sessions
|
|
240
|
+
- when the gateway restarts, an already-open local UI tab reconnects and refreshes itself instead of spawning a redundant new tab
|
|
232
241
|
- `/echo-memory view` returns the current localhost URL for the local markdown workspace UI and also tries to open the browser
|
|
233
242
|
- natural-language requests can use the `echo_memory_local_ui` tool to get the exact live URL instead of guessing the port
|
|
234
243
|
- the local markdown archive stays fully browsable even when no Echo Cloud API key is configured
|
package/clawdbot.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "echo-memory-cloud-openclaw-plugin",
|
|
3
3
|
"name": "Echo Memory Cloud OpenClaw Plugin",
|
|
4
4
|
"description": "Sync OpenClaw local markdown memory files to Echo cloud",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.1",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"main": "./index.js",
|
|
8
8
|
"configSchema": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"memoryDir": {
|
|
24
24
|
"type": "string",
|
|
25
|
-
"description": "Absolute path to the markdown memory directory. Falls back to ECHOMEM_MEMORY_DIR, then ~/.
|
|
25
|
+
"description": "Absolute path to the markdown memory directory. Falls back to ECHOMEM_MEMORY_DIR, then ~/.openclaw/workspace/memory."
|
|
26
26
|
},
|
|
27
27
|
"localOnlyMode": {
|
|
28
28
|
"type": "boolean",
|
package/index.js
CHANGED
|
@@ -15,7 +15,14 @@ import {
|
|
|
15
15
|
import { buildOnboardingText } from "./lib/onboarding.js";
|
|
16
16
|
import { createSyncRunner, formatStatusText } from "./lib/sync.js";
|
|
17
17
|
import { readLastSyncState } from "./lib/state.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
openUrlInDefaultBrowser,
|
|
20
|
+
startLocalServer,
|
|
21
|
+
stopLocalServer,
|
|
22
|
+
waitForLocalUiClient,
|
|
23
|
+
} from "./lib/local-server.js";
|
|
24
|
+
|
|
25
|
+
const LOCAL_UI_RECONNECT_GRACE_MS = 1500;
|
|
19
26
|
|
|
20
27
|
function resolveCommandLabel(channel) {
|
|
21
28
|
return channel === "discord" ? "/echomemory" : "/echo-memory";
|
|
@@ -75,10 +82,15 @@ export default {
|
|
|
75
82
|
let openedInBrowser = false;
|
|
76
83
|
let openReason = "not_requested";
|
|
77
84
|
if (openInBrowser) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
const existingClientDetected = trigger === "gateway-start"
|
|
86
|
+
? await waitForLocalUiClient({ timeoutMs: LOCAL_UI_RECONNECT_GRACE_MS })
|
|
87
|
+
: false;
|
|
88
|
+
const openResult = existingClientDetected
|
|
89
|
+
? { opened: false, reason: "existing_client_reconnected" }
|
|
90
|
+
: await openUrlInDefaultBrowser(url, {
|
|
91
|
+
logger: api.logger,
|
|
92
|
+
force: trigger !== "gateway-start",
|
|
93
|
+
});
|
|
82
94
|
openedInBrowser = openResult.opened;
|
|
83
95
|
openReason = openResult.reason;
|
|
84
96
|
}
|
|
@@ -130,7 +142,18 @@ export default {
|
|
|
130
142
|
});
|
|
131
143
|
}
|
|
132
144
|
|
|
133
|
-
const envStatus = getEnvFileStatus();
|
|
145
|
+
const envStatus = getEnvFileStatus();
|
|
146
|
+
if (envStatus.usingLegacyBridge) {
|
|
147
|
+
api.logger?.warn?.(
|
|
148
|
+
`[echo-memory] Legacy env file detected (${envStatus.legacyPaths.join(", ")}). ` +
|
|
149
|
+
`EchoMemory is using a one-release migration bridge and will write future changes to ${envStatus.primaryPath}.`,
|
|
150
|
+
);
|
|
151
|
+
} else if (envStatus.legacyPaths.length > 0) {
|
|
152
|
+
api.logger?.info?.(
|
|
153
|
+
`[echo-memory] Legacy env file still present (${envStatus.legacyPaths.join(", ")}). ` +
|
|
154
|
+
`Current settings should be kept in ${envStatus.primaryPath}.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
134
157
|
if (!envStatus.found) {
|
|
135
158
|
api.logger?.warn?.(
|
|
136
159
|
`[echo-memory] No .env file found in ${envStatus.searchPaths.join(", ")}. Using plugin config or process env.`,
|
package/lib/config.js
CHANGED
|
@@ -4,12 +4,25 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_BASE_URL = "https://echo-mem-chrome.vercel.app";
|
|
6
6
|
const DEFAULT_WEB_BASE_URL = "https://www.iditor.com";
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
7
|
+
function resolveOpenClawHome() {
|
|
8
|
+
const configPath = process.env.OPENCLAW_CONFIG_PATH?.trim();
|
|
9
|
+
if (configPath) {
|
|
10
|
+
return dirname(configPath);
|
|
11
|
+
}
|
|
12
|
+
return join(homedir(), ".openclaw");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OPENCLAW_HOME = resolveOpenClawHome();
|
|
16
|
+
const PRIMARY_ENV_PATH = join(OPENCLAW_HOME, ".env");
|
|
17
|
+
const LEGACY_ENV_SOURCES = [
|
|
10
18
|
join(homedir(), ".moltbot", ".env"),
|
|
11
19
|
join(homedir(), ".clawdbot", ".env"),
|
|
12
20
|
];
|
|
21
|
+
const DEFAULT_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || join(OPENCLAW_HOME, "openclaw.json");
|
|
22
|
+
const ENV_SOURCES = [
|
|
23
|
+
PRIMARY_ENV_PATH,
|
|
24
|
+
...LEGACY_ENV_SOURCES,
|
|
25
|
+
];
|
|
13
26
|
|
|
14
27
|
let cachedEnv = null;
|
|
15
28
|
|
|
@@ -97,10 +110,15 @@ function parseInteger(value, fallback, { min = Number.NEGATIVE_INFINITY, max = N
|
|
|
97
110
|
|
|
98
111
|
export function getEnvFileStatus() {
|
|
99
112
|
const env = loadEnvFiles();
|
|
113
|
+
const legacyPaths = env.foundPaths.filter((envPath) => LEGACY_ENV_SOURCES.includes(envPath));
|
|
100
114
|
return {
|
|
101
115
|
found: env.foundPaths.length > 0,
|
|
102
116
|
paths: env.foundPaths,
|
|
103
117
|
searchPaths: env.searchPaths,
|
|
118
|
+
primaryPath: PRIMARY_ENV_PATH,
|
|
119
|
+
foundPrimary: env.foundPaths.includes(PRIMARY_ENV_PATH),
|
|
120
|
+
legacyPaths,
|
|
121
|
+
usingLegacyBridge: !env.foundPaths.includes(PRIMARY_ENV_PATH) && legacyPaths.length > 0,
|
|
104
122
|
};
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -142,7 +160,6 @@ function maskValue(value, { keepStart = 2, keepEnd = 2 } = {}) {
|
|
|
142
160
|
export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
|
|
143
161
|
const runtimeCfg = cfg ?? buildConfig(pluginConfig);
|
|
144
162
|
const envStatus = getEnvFileStatus();
|
|
145
|
-
const targetEnvPath = envStatus.paths[0] || envStatus.searchPaths[0];
|
|
146
163
|
const localOnlyMode = resolveConfigValue(
|
|
147
164
|
pluginConfig,
|
|
148
165
|
"localOnlyMode",
|
|
@@ -162,9 +179,12 @@ export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
|
|
|
162
179
|
targetPath: DEFAULT_CONFIG_PATH,
|
|
163
180
|
},
|
|
164
181
|
envFile: {
|
|
165
|
-
targetPath:
|
|
182
|
+
targetPath: envStatus.primaryPath,
|
|
183
|
+
activePath: envStatus.paths[0] || null,
|
|
166
184
|
foundPaths: envStatus.paths,
|
|
167
185
|
searchPaths: envStatus.searchPaths,
|
|
186
|
+
legacyPaths: envStatus.legacyPaths,
|
|
187
|
+
usingLegacyBridge: envStatus.usingLegacyBridge,
|
|
168
188
|
},
|
|
169
189
|
localOnlyMode: {
|
|
170
190
|
enabled: Boolean(runtimeCfg.localOnlyMode),
|
|
@@ -190,12 +210,13 @@ export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
|
|
|
190
210
|
|
|
191
211
|
export function saveLocalUiSetup(values = {}) {
|
|
192
212
|
const envStatus = getEnvFileStatus();
|
|
193
|
-
const targetPath = envStatus.
|
|
213
|
+
const targetPath = envStatus.primaryPath;
|
|
214
|
+
const sourcePath = envStatus.foundPrimary ? envStatus.primaryPath : (envStatus.paths[0] || envStatus.primaryPath);
|
|
194
215
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
195
216
|
|
|
196
217
|
let lines = [];
|
|
197
218
|
try {
|
|
198
|
-
lines = readFileSync(
|
|
219
|
+
lines = readFileSync(sourcePath, "utf8").split(/\r?\n/);
|
|
199
220
|
} catch {
|
|
200
221
|
lines = [];
|
|
201
222
|
}
|
|
@@ -237,6 +258,8 @@ export function saveLocalUiSetup(values = {}) {
|
|
|
237
258
|
invalidateEnvCache();
|
|
238
259
|
return {
|
|
239
260
|
targetPath,
|
|
261
|
+
sourcePath,
|
|
262
|
+
migratedFrom: sourcePath !== targetPath ? sourcePath : null,
|
|
240
263
|
savedKeys: [...managedKeys],
|
|
241
264
|
};
|
|
242
265
|
}
|
|
@@ -275,7 +298,7 @@ export function buildConfig(pluginConfig = {}) {
|
|
|
275
298
|
memoryDir: String(
|
|
276
299
|
cfg.memoryDir
|
|
277
300
|
|| loadEnvVar("ECHOMEM_MEMORY_DIR")
|
|
278
|
-
|| join(
|
|
301
|
+
|| join(OPENCLAW_HOME, "workspace", "memory"),
|
|
279
302
|
).trim(),
|
|
280
303
|
};
|
|
281
304
|
}
|
package/lib/local-server.js
CHANGED
|
@@ -24,11 +24,18 @@ let _lastOpenedUrl = null;
|
|
|
24
24
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", "logs", "completions", "delivery-queue", "browser", "canvas", "cron", "media"]);
|
|
25
25
|
|
|
26
26
|
/** Debounced file-change broadcaster */
|
|
27
|
-
function createFileWatcher(workspaceDir) {
|
|
28
|
-
const sseClients = new Set();
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
function createFileWatcher(workspaceDir) {
|
|
28
|
+
const sseClients = new Set();
|
|
29
|
+
const clientWaiters = new Set();
|
|
30
|
+
const watchers = [];
|
|
31
|
+
let debounceTimer = null;
|
|
32
|
+
const DEBOUNCE_MS = 500;
|
|
33
|
+
|
|
34
|
+
function settleClientWaiters(didConnect) {
|
|
35
|
+
for (const finish of [...clientWaiters]) {
|
|
36
|
+
finish(didConnect);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
32
39
|
|
|
33
40
|
function broadcast(eventData) {
|
|
34
41
|
const payload = `data: ${JSON.stringify(eventData)}\n\n`;
|
|
@@ -66,18 +73,48 @@ function createFileWatcher(workspaceDir) {
|
|
|
66
73
|
// Start watching
|
|
67
74
|
watchRecursive(workspaceDir);
|
|
68
75
|
|
|
69
|
-
return {
|
|
70
|
-
sseClients,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
return {
|
|
77
|
+
sseClients,
|
|
78
|
+
addSseClient(res) {
|
|
79
|
+
sseClients.add(res);
|
|
80
|
+
settleClientWaiters(true);
|
|
81
|
+
},
|
|
82
|
+
removeSseClient(res) {
|
|
83
|
+
sseClients.delete(res);
|
|
84
|
+
},
|
|
85
|
+
waitForClient(timeoutMs = 0) {
|
|
86
|
+
if (sseClients.size > 0) {
|
|
87
|
+
return Promise.resolve(true);
|
|
88
|
+
}
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let timer = null;
|
|
91
|
+
const finish = (didConnect) => {
|
|
92
|
+
if (!clientWaiters.delete(finish)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (timer) {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
timer = null;
|
|
98
|
+
}
|
|
99
|
+
resolve(didConnect);
|
|
100
|
+
};
|
|
101
|
+
clientWaiters.add(finish);
|
|
102
|
+
if (timeoutMs > 0) {
|
|
103
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
broadcast,
|
|
108
|
+
close() {
|
|
109
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
110
|
+
for (const w of watchers) { try { w.close(); } catch {} }
|
|
111
|
+
watchers.length = 0;
|
|
112
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
113
|
+
sseClients.clear();
|
|
114
|
+
settleClientWaiters(false);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
81
118
|
|
|
82
119
|
function tryListen(server, port) {
|
|
83
120
|
return new Promise((resolve) => {
|
|
@@ -388,10 +425,10 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
|
|
|
388
425
|
};
|
|
389
426
|
}
|
|
390
427
|
|
|
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;
|
|
428
|
+
function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
429
|
+
const normalizedBase = path.resolve(workspaceDir) + path.sep;
|
|
430
|
+
const { apiClient, syncRunner, cfg, fileWatcher, logger, serverInstanceId } = opts;
|
|
431
|
+
const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
|
|
395
432
|
|
|
396
433
|
return async function handler(req, res) {
|
|
397
434
|
setCorsHeaders(res);
|
|
@@ -415,19 +452,24 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
415
452
|
}
|
|
416
453
|
|
|
417
454
|
// 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(":
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
455
|
+
if (url.pathname === "/api/events") {
|
|
456
|
+
res.writeHead(200, {
|
|
457
|
+
"Content-Type": "text/event-stream",
|
|
458
|
+
"Cache-Control": "no-cache",
|
|
459
|
+
"Connection": "keep-alive",
|
|
460
|
+
});
|
|
461
|
+
res.write("retry: 1000\n");
|
|
462
|
+
res.write(`data: ${JSON.stringify({
|
|
463
|
+
type: "server-connected",
|
|
464
|
+
serverInstanceId,
|
|
465
|
+
connectedAt: new Date().toISOString(),
|
|
466
|
+
})}\n\n`);
|
|
467
|
+
if (fileWatcher) {
|
|
468
|
+
fileWatcher.addSseClient(res);
|
|
469
|
+
req.on("close", () => fileWatcher.removeSseClient(res));
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
431
473
|
|
|
432
474
|
if (url.pathname === "/") {
|
|
433
475
|
// Always serve the built React app from dist/
|
|
@@ -576,11 +618,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
576
618
|
typeof body.apiKey === "string" && body.apiKey.trim()
|
|
577
619
|
? "false"
|
|
578
620
|
: "true",
|
|
579
|
-
};
|
|
580
|
-
const saveResult = saveLocalUiSetup(payload);
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
621
|
+
};
|
|
622
|
+
const saveResult = saveLocalUiSetup(payload);
|
|
623
|
+
if (saveResult.migratedFrom) {
|
|
624
|
+
logger?.info?.(
|
|
625
|
+
`[echo-memory] Migrated local UI setup from ${saveResult.migratedFrom} to ${saveResult.targetPath}`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (cfg) {
|
|
629
|
+
cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
|
|
630
|
+
cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
|
|
584
631
|
cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
|
|
585
632
|
}
|
|
586
633
|
sendJson(res, {
|
|
@@ -794,15 +841,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
794
841
|
};
|
|
795
842
|
}
|
|
796
843
|
|
|
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
|
|
844
|
+
export async function startLocalServer(workspaceDir, opts = {}) {
|
|
845
|
+
if (_instance) {
|
|
846
|
+
return _instance.url;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
await ensureLocalUiReady(opts.cfg, opts.logger);
|
|
850
|
+
const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
|
|
851
|
+
const fileWatcher = createFileWatcher(workspaceDir);
|
|
852
|
+
const serverInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
853
|
+
const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
|
|
806
854
|
? opts.syncRunner.onProgress((event) => {
|
|
807
855
|
const mapPath = (targetPath) => {
|
|
808
856
|
if (!targetPath) return null;
|
|
@@ -832,8 +880,8 @@ export async function startLocalServer(workspaceDir, opts = {}) {
|
|
|
832
880
|
});
|
|
833
881
|
})
|
|
834
882
|
: null;
|
|
835
|
-
const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
|
|
836
|
-
const server = http.createServer(handler);
|
|
883
|
+
const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher, serverInstanceId });
|
|
884
|
+
const server = http.createServer(handler);
|
|
837
885
|
|
|
838
886
|
let port = null;
|
|
839
887
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -849,11 +897,15 @@ export async function startLocalServer(workspaceDir, opts = {}) {
|
|
|
849
897
|
fileWatcher.close();
|
|
850
898
|
throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
|
|
851
899
|
}
|
|
852
|
-
|
|
853
|
-
const url = `http://127.0.0.1:${port}`;
|
|
854
|
-
_instance = { server, url, fileWatcher, unsubscribeSyncProgress };
|
|
855
|
-
return url;
|
|
856
|
-
}
|
|
900
|
+
|
|
901
|
+
const url = `http://127.0.0.1:${port}`;
|
|
902
|
+
_instance = { server, url, fileWatcher, unsubscribeSyncProgress, serverInstanceId };
|
|
903
|
+
return url;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export async function waitForLocalUiClient({ timeoutMs = 0 } = {}) {
|
|
907
|
+
return _instance?.fileWatcher?.waitForClient(timeoutMs) ?? false;
|
|
908
|
+
}
|
|
857
909
|
|
|
858
910
|
export function stopLocalServer() {
|
|
859
911
|
if (_instance) {
|