@weavetab/mcp 2.5.0-beta.1 → 2.5.0-beta.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.
Files changed (89) hide show
  1. package/AGENTS.md +1 -0
  2. package/LICENSE +2 -2
  3. package/README.md +208 -43
  4. package/SECURITY.md +3 -3
  5. package/TROUBLESHOOTING.md +1 -1
  6. package/dist/cdp/bridge.d.ts +10 -0
  7. package/dist/cdp/bridge.js +284 -78
  8. package/dist/cdp/connector.d.ts +5 -3
  9. package/dist/cdp/connector.js +188 -71
  10. package/dist/cdp/lock.d.ts +9 -6
  11. package/dist/cdp/lock.js +72 -59
  12. package/dist/cdp/omni_broker.d.ts +1 -0
  13. package/dist/cdp/omni_broker.js +12 -6
  14. package/dist/cdp/profiles.d.ts +10 -0
  15. package/dist/cdp/profiles.js +65 -0
  16. package/dist/cdp/walker.js +50 -0
  17. package/dist/cli/plugin.d.ts +0 -2
  18. package/dist/cli/plugin.js +31 -683
  19. package/dist/cli/updater.js +2 -2
  20. package/dist/config/loader.d.ts +25 -6
  21. package/dist/config/loader.js +133 -20
  22. package/dist/index.js +48 -52
  23. package/dist/overlay/ghost.js +2 -2
  24. package/dist/overlay/injector.js +1 -3
  25. package/dist/plugin/extension_bridge.d.ts +40 -0
  26. package/dist/plugin/extension_bridge.js +158 -0
  27. package/dist/plugin/interface.d.ts +78 -16
  28. package/dist/plugin/loader.d.ts +4 -0
  29. package/dist/plugin/loader.js +178 -57
  30. package/dist/plugin/registry.js +11 -1
  31. package/dist/plugin/storage.d.ts +51 -0
  32. package/dist/plugin/storage.js +164 -0
  33. package/dist/server.js +213 -121
  34. package/dist/state/session.d.ts +2 -2
  35. package/dist/state/session.js +33 -13
  36. package/dist/tools/browser_detect.d.ts +2 -1
  37. package/dist/tools/browser_detect.js +6 -3
  38. package/dist/tools/burst.d.ts +1 -0
  39. package/dist/tools/burst.js +4 -6
  40. package/dist/tools/click.js +23 -28
  41. package/dist/tools/clipboard.js +31 -24
  42. package/dist/tools/navigate.d.ts +1 -1
  43. package/dist/tools/navigate.js +28 -10
  44. package/dist/tools/tabs.d.ts +10 -10
  45. package/dist/tools/tabs.js +47 -39
  46. package/dist/tools/type.js +2 -2
  47. package/docs/DOCUMENTATION.md +63 -6
  48. package/docs/architecture/blind-injection.md +20 -0
  49. package/docs/architecture/dual-injection.md +32 -0
  50. package/docs/architecture/overview.md +49 -0
  51. package/docs/architecture/security.md +35 -0
  52. package/docs/architecture/transport.md +37 -0
  53. package/docs/captcha-stealth/captcha-solver.md +75 -0
  54. package/docs/captcha-stealth/stealth-physics.md +60 -0
  55. package/docs/cli/cli-commands.md +44 -0
  56. package/docs/cli/cli-flags.md +20 -0
  57. package/docs/cli/cli-introduction.md +20 -0
  58. package/docs/cli/cli-scripting.md +32 -0
  59. package/docs/cli/g-ide/ide-antigravity.md +24 -0
  60. package/docs/cli/g-ide/ide-claude-code.md +24 -0
  61. package/docs/cli/g-ide/ide-claude-desktop.md +32 -0
  62. package/docs/cli/g-ide/ide-cursor.md +31 -0
  63. package/docs/cli/g-ide/ide-generic.md +34 -0
  64. package/docs/cli/g-ide/ide-opencode.md +24 -0
  65. package/docs/cli/g-ide/ide-vscode-copilot.md +24 -0
  66. package/docs/cli/g-ide/ide-windsurf.md +24 -0
  67. package/docs/configuration/config-locations.md +24 -0
  68. package/docs/configuration/configuration.md +73 -0
  69. package/docs/configuration/env-variables.md +19 -0
  70. package/docs/configuration/folders.md +54 -0
  71. package/docs/desktop-automation/electron-apps.md +162 -0
  72. package/docs/get-started/installation.md +47 -0
  73. package/docs/get-started/introduction.md +60 -0
  74. package/docs/get-started/quickstart.md +54 -0
  75. package/docs/get-started/setup-wizard.md +26 -0
  76. package/docs/skills-ecosystem/skills-overview.md +35 -0
  77. package/docs/skills-ecosystem/skills-recipes.md +36 -0
  78. package/docs/tools/tools.md +293 -0
  79. package/docs/troubleshooting/trouble-browser.md +27 -0
  80. package/docs/troubleshooting/trouble-config.md +20 -0
  81. package/docs/troubleshooting/trouble-loop.md +31 -0
  82. package/ext-dist/content.js +0 -1
  83. package/mcp.json +8 -6
  84. package/package.json +11 -5
  85. package/server.json +3 -3
  86. package/docs/DEV_MODE.md +0 -68
  87. package/docs/PLUGINS.md +0 -538
  88. package/docs/TOOLS.md +0 -104
  89. package/docs/dev_plugin.md +0 -310
@@ -7,9 +7,9 @@ import { logDebug } from "../audit/logger.js";
7
7
  */
8
8
  import CDP from "chrome-remote-interface";
9
9
  import { blockStorageAccess } from "../security/guard.js";
10
- import { launchWithBridge, injectStealthScript } from "./bridge.js";
10
+ import { launchWithBridge, launchElectronApp, injectStealthScript } from "./bridge.js";
11
11
  import { setupOverlay, ensureVisualLayer } from "../overlay/injector.js";
12
- import { onConfigChange, saveConfig, loadConfigSync, getCachedConfig, getConfigAgentId, getConfigCookies, getConfigProfileDir, getConfigMode } from "../config/loader.js";
12
+ import { onConfigChange, saveConfig, loadConfigSync, getCachedConfig, getConfigAgentId, getConfigCookies, getConfigProfileDir, getConfigMode, getElectronAppConfig, getConfigExtension } from "../config/loader.js";
13
13
  import { sessionRegistry } from "../state/session.js";
14
14
  import { initConsoleBuffering } from "../tools/console.js";
15
15
  import { registerMutationListener, clearTabCache } from "../state/cache.js";
@@ -95,12 +95,21 @@ async function waitForPageReady(session) {
95
95
  });
96
96
  }
97
97
  // ─── Session init (overlay + re-injection) ───────────────────────────────────
98
- async function initSession(session, targetId, port, config) {
98
+ async function initSession(session, targetId, port, config, isElectron) {
99
99
  initDialogHandler(session);
100
100
  // Initialize Omni-Context Broker for iframes
101
101
  const { initOmniBroker } = await import("./omni_broker.js");
102
102
  await initOmniBroker(session, targetId, port);
103
- if (activeEngine === "chromium") {
103
+ if (isElectron) {
104
+ // Electron desktop app initialization
105
+ try {
106
+ await session.Page.addScriptToEvaluateOnNewDocument({ source: WT_REGISTRY_INIT_EXPR });
107
+ }
108
+ catch { /* non-fatal */ }
109
+ // Direct DOM minimal overlay injection without Chrome Extension
110
+ await ensureVisualLayer(session, config);
111
+ }
112
+ else if (activeEngine === "chromium") {
104
113
  // Only block cookies/storage when cookies mode is disabled
105
114
  if (!getConfigCookies(config)) {
106
115
  await blockStorageAccess(session);
@@ -142,7 +151,7 @@ async function initSession(session, targetId, port, config) {
142
151
  clearTabCache(targetId);
143
152
  // Invalidate URC on navigation — elements from the old page are stale
144
153
  urcInvalidate(targetId);
145
- if (activeEngine === "chromium") {
154
+ if (isElectron || activeEngine === "chromium") {
146
155
  try {
147
156
  await waitForPageReady(session);
148
157
  const cfg = getCachedConfig();
@@ -154,8 +163,8 @@ async function initSession(session, targetId, port, config) {
154
163
  catch { /* ignore */ }
155
164
  }
156
165
  });
157
- // Diagnostic: verify extension content script loaded
158
- if (activeEngine === "chromium") {
166
+ // Diagnostic: verify extension content script loaded (Standard browser only)
167
+ if (!isElectron && activeEngine === "chromium" && getConfigExtension(config) !== false) {
159
168
  try {
160
169
  // Check for multiple indicators that extension is loaded
161
170
  const { result: extCheck } = await session.Runtime.evaluate({
@@ -174,29 +183,12 @@ async function initSession(session, targetId, port, config) {
174
183
  else {
175
184
  logDebug(`[Weavetab] [!] Extension content script NOT detected — HUD/overlay may not work`);
176
185
  logDebug(`[Weavetab] [!] Try reloading page or check if extension loaded in browser://extensions`);
177
- // Try to inject a basic error indicator into the page
178
- try {
179
- await session.Runtime.evaluate({
180
- expression: `
181
- (function() {
182
- const div = document.createElement('div');
183
- div.style.cssText = 'position:fixed;top:10px;right:10px;background:red;color:white;padding:10px;z-index:999999;font-family:sans-serif;font-size:14px;';
184
- div.textContent = 'WeaveTab Extension Not Loaded';
185
- document.body.appendChild(div);
186
- setTimeout(() => div.remove(), 5000);
187
- })()
188
- `
189
- });
190
- }
191
- catch { /* ignore */ }
192
186
  }
193
187
  }
194
188
  catch (checkErr) {
195
189
  logDebug(`[Weavetab] [!] Extension health check failed: ${checkErr}`);
196
190
  }
197
- }
198
- // Push config + initial idle state to extension right away (Chrome only)
199
- if (activeEngine === "chromium") {
191
+ // Push config + initial idle state to extension right away (Chrome only)
200
192
  const { sendToExtension } = await import("../cdp/helpers.js");
201
193
  sendToExtension(session, {
202
194
  type: 'Weavetab_CONFIG',
@@ -207,8 +199,7 @@ async function initSession(session, targetId, port, config) {
207
199
  state: 'idle',
208
200
  }).catch(() => { });
209
201
  }
210
- // Analytics ping every 5s — stored per-tab so each interval can be
211
- // independently cancelled when the tab closes or cleanupAll() is called.
202
+ // Analytics ping every 30s
212
203
  const sessionStartTime = Date.now();
213
204
  const analyticsInterval = setInterval(async () => {
214
205
  try {
@@ -225,34 +216,135 @@ async function initSession(session, targetId, port, config) {
225
216
  }).catch(() => { });
226
217
  }
227
218
  catch { /* ignore */ }
228
- }, 30_000); // 30s interval — reduced from 5s to cut idle CDP overhead (6 round-trips/min → 1)
229
- // Register in the per-tab map so cleanupAll() and the session-close handler can
230
- // cancel it without relying on a single overwriteable global slot.
219
+ }, 30_000);
231
220
  analyticsIntervals.set(targetId, analyticsInterval);
232
- // unref() so this polling interval does not pin the Node.js event loop.
233
- // An orphaned MCP process (IDE disconnected, browser closed) would otherwise
234
- // stay alive indefinitely due to this 30s interval firing forever.
235
221
  analyticsInterval.unref();
236
222
  }
237
223
  // ─── Main connection entry point ──────────────────────────────────────────────
238
- export async function getOrConnect(config) {
239
- if (getConfigMode(config) === "attach") {
224
+ export async function getOrConnect(config, targetApp) {
225
+ if (getConfigMode(config) === "attach" && !targetApp) {
240
226
  throw new Error("[!] Attach mode (connect to your real browser) coming soon. Use session mode for now.");
241
227
  }
242
- const activeClient = sessionRegistry.getActive(getConfigAgentId(config));
228
+ // 1. Check existing active session for this agent & app
229
+ const activeClient = sessionRegistry.getActive(getConfigAgentId(config), targetApp);
243
230
  if (activeClient) {
244
231
  try {
245
232
  await activeClient.session.Browser.getVersion();
246
233
  return activeClient;
247
234
  }
248
235
  catch {
249
- logDebug("[Weavetab CDP] Connection lost. Reconnecting...");
236
+ logDebug(`[Weavetab CDP] Connection lost for ${targetApp || "browser"}. Reconnecting...`);
250
237
  clearProfileVerificationCache(activeClient.targetId);
251
238
  sessionRegistry.unregister(activeClient.targetId);
252
239
  }
253
240
  }
254
- const releaseMutex = await acquireMutex();
241
+ const releaseMutex = await acquireMutex(targetApp);
255
242
  try {
243
+ if (targetApp) {
244
+ // ──────────────── ELECTRON APP CONNECTION FLOW ────────────────
245
+ const appCfg = getElectronAppConfig(targetApp, config);
246
+ if (!appCfg) {
247
+ throw new Error(`[Weavetab] ✗ Electron app "${targetApp}" is not configured in config.json.\n` +
248
+ ` Please add an entry under "electron": { "${targetApp}": "C:\\\\path\\\\to\\\\app.exe" } in your config.`);
249
+ }
250
+ let port = null;
251
+ const sessionLock = readLock(targetApp);
252
+ if (sessionLock && sessionLock.mode === "session") {
253
+ if (isLockPending(sessionLock)) {
254
+ logDebug(`[Weavetab] Launch in progress for ${targetApp} by PID ${sessionLock.pid}. Waiting up to 5s...`);
255
+ for (let i = 0; i < 5; i++) {
256
+ await new Promise(r => setTimeout(r, 1000));
257
+ const updatedLock = readLock(targetApp);
258
+ if (!updatedLock || !isLockPending(updatedLock))
259
+ break;
260
+ }
261
+ }
262
+ const freshLock = readLock(targetApp);
263
+ if (freshLock && (await isLockValid(freshLock))) {
264
+ port = freshLock.port;
265
+ }
266
+ else {
267
+ clearLock(targetApp);
268
+ }
269
+ }
270
+ let targets = [];
271
+ if (port) {
272
+ try {
273
+ targets = await withCDPTimeout(CDP.List({ host: "127.0.0.1", port }), 3000, "CDP.List existing Electron");
274
+ }
275
+ catch {
276
+ port = null;
277
+ }
278
+ }
279
+ if (!port || targets.length === 0) {
280
+ logDebug(`[Weavetab CDP] Launching Electron app ${targetApp}...`);
281
+ const launchResult = await launchElectronApp(targetApp, config);
282
+ port = launchResult.port;
283
+ // Poll for target window availability (Electron may take up to 2-3s to render initial window)
284
+ for (let retry = 0; retry < 10; retry++) {
285
+ try {
286
+ targets = await withCDPTimeout(CDP.List({ host: "127.0.0.1", port }), 2000, "CDP.List Electron retry");
287
+ if (targets.length > 0)
288
+ break;
289
+ }
290
+ catch { /* wait */ }
291
+ await new Promise(r => setTimeout(r, 500));
292
+ }
293
+ }
294
+ let pageTarget = targets.find(t => t.type === "page" || t.type === "webview" || t.type === "app") ?? targets[0];
295
+ if (!pageTarget) {
296
+ throw new Error(`[Weavetab] ✗ No window or webview target found in Electron app "${targetApp}".`);
297
+ }
298
+ const targetId = pageTarget.id || pageTarget.targetId;
299
+ const session = await withCDPTimeout(CDP({ host: "127.0.0.1", port, target: targetId }), 5000, "CDP connect Electron target");
300
+ session.on("close", () => {
301
+ logDebug(`[Weavetab CDP] Electron session WebSocket closed (${targetApp}) — unregistering.`);
302
+ const iv = analyticsIntervals.get(targetId);
303
+ if (iv) {
304
+ clearInterval(iv);
305
+ analyticsIntervals.delete(targetId);
306
+ }
307
+ clearProfileVerificationCache(targetId);
308
+ import("./omni_broker.js").then(m => m.cleanupOmniBroker(targetId)).catch(() => { });
309
+ import("../tools/network_intercept.js").then(m => m.cleanupNetworkIntercept(session)).catch(() => { });
310
+ sessionRegistry.unregister(targetId);
311
+ });
312
+ session.on("error", (err) => {
313
+ logDebug(`[Weavetab CDP] Electron session WebSocket error (${targetApp}):`, err?.message ?? err);
314
+ });
315
+ // Validate connection health
316
+ try {
317
+ await session.Browser.getVersion();
318
+ }
319
+ catch {
320
+ throw new Error("Failed health check: CDP session connected to Electron app but getVersion failed.");
321
+ }
322
+ await Promise.all([
323
+ session.Accessibility.enable(),
324
+ session.DOM.enable(),
325
+ session.Page.enable(),
326
+ session.Runtime.enable(),
327
+ session.Network.enable().catch(() => { }),
328
+ session.Performance.enable().catch(() => { })
329
+ ]);
330
+ await wireUiConfigBinding(session);
331
+ await initSession(session, targetId, port, config, true);
332
+ await session.Target.setDiscoverTargets({ discover: true }).catch(() => { });
333
+ logDebug(`[Weavetab CDP] [OK] Active Electron target: ${pageTarget.title || pageTarget.url || targetApp}`);
334
+ const client = {
335
+ session,
336
+ targetId,
337
+ url: pageTarget.url || "",
338
+ title: pageTarget.title || targetApp,
339
+ overlayInstalled: false,
340
+ agentId: getConfigAgentId(config),
341
+ appName: targetApp,
342
+ isElectron: true,
343
+ };
344
+ sessionRegistry.register(targetId, client);
345
+ return client;
346
+ }
347
+ // ──────────────── STANDARD WEB BROWSER FLOW ────────────────
256
348
  const sessionLock = readLock();
257
349
  if (sessionLock && sessionLock.mode === "session") {
258
350
  if (isLockPending(sessionLock)) {
@@ -437,7 +529,7 @@ export async function getOrConnect(config) {
437
529
  if (activatedId) {
438
530
  const client = sessionRegistry.getAll().find(c => c.targetId === activatedId);
439
531
  if (client) {
440
- sessionRegistry.setActive(activatedId, client.agentId);
532
+ sessionRegistry.setActive(activatedId, client.agentId, client.appName);
441
533
  }
442
534
  }
443
535
  });
@@ -491,28 +583,34 @@ export async function getOrConnect(config) {
491
583
  await releaseMutex();
492
584
  }
493
585
  }
494
- export async function getTabList(agentId) {
586
+ export async function getTabList(agentId, targetApp) {
495
587
  const config = getCachedConfig();
496
- // Validate the lock before blindly connecting (same two-factor check that
497
- // getOrConnect() performs). A stale lock after a server restart would otherwise
498
- // produce raw ECONNREFUSED.
499
- let port = actualPort;
500
- if (!port) {
501
- const lock = readLock();
502
- if (lock && lock.mode === "session") {
503
- const valid = await isLockValid(lock);
504
- if (valid) {
505
- port = lock.port;
506
- }
507
- else {
508
- logDebug("[Weavetab CDP] getTabList: stale lock detected — clearing.");
509
- clearLock();
510
- port = null;
588
+ let port = null;
589
+ if (targetApp) {
590
+ const lock = readLock(targetApp);
591
+ if (lock && (await isLockValid(lock))) {
592
+ port = lock.port;
593
+ }
594
+ }
595
+ else {
596
+ port = actualPort;
597
+ if (!port) {
598
+ const lock = readLock();
599
+ if (lock && lock.mode === "session") {
600
+ const valid = await isLockValid(lock);
601
+ if (valid) {
602
+ port = lock.port;
603
+ }
604
+ else {
605
+ logDebug("[Weavetab CDP] getTabList: stale lock detected — clearing.");
606
+ clearLock();
607
+ port = null;
608
+ }
511
609
  }
512
610
  }
513
611
  }
514
612
  if (!port)
515
- throw new Error("[!] No active browser session. Run browser_go first.");
613
+ throw new Error(`[!] No active ${targetApp ? targetApp : "browser"} session. Run browser_go or launch app first.`);
516
614
  try {
517
615
  const metaSession = await withCDPTimeout(CDP({ host: "127.0.0.1", port }), 5000, "getTabList CDP");
518
616
  const { targetInfos } = await withCDPTimeout(metaSession.Target.getTargets(), 5000, "getTabList getTargets");
@@ -520,21 +618,21 @@ export async function getTabList(agentId) {
520
618
  const aid = agentId || getConfigAgentId(config);
521
619
  const bctxId = sessionRegistry.getBrowserContextIdForAgent(aid);
522
620
  return targetInfos
523
- .filter(t => t.type === "page" && (!bctxId || t.browserContextId === bctxId))
621
+ .filter(t => (t.type === "page" || t.type === "webview" || t.type === "app" || t.type === "other" || t.type === "iframe") && (!bctxId || t.browserContextId === bctxId))
524
622
  .map(t => ({
525
623
  id: t.targetId,
526
- title: t.title,
624
+ title: t.title || t.url || t.type,
527
625
  url: t.url,
528
626
  type: t.type,
529
627
  }));
530
628
  }
531
629
  catch {
532
- throw new Error(`[!] Weavetab cannot reach browser on port ${port}.`);
630
+ throw new Error(`[!] Weavetab cannot reach ${targetApp ? targetApp : "browser"} on port ${port}.`);
533
631
  }
534
632
  }
535
633
  // Only allow the UI to update cosmetic/dev settings via the binding.
536
634
  // Never allow overwriting security-critical fields (role, browserPath, block, allow, profileDir, etc.).
537
- const UI_ALLOWED_FIELDS = new Set(["devMode", "visuals", "darkMode"]);
635
+ const UI_ALLOWED_FIELDS = new Set(["devMode", "extension"]);
538
636
  async function wireUiConfigBinding(session) {
539
637
  try {
540
638
  await session.Runtime.addBinding({ name: "__wt_save_ui_config" });
@@ -562,21 +660,38 @@ async function wireUiConfigBinding(session) {
562
660
  }
563
661
  catch { /* already bound */ }
564
662
  }
565
- export async function connectToTab(targetId, port, agentId, browserContextId) {
663
+ export async function connectToTab(targetId, port, agentId, browserContextId, appName) {
566
664
  const existing = sessionRegistry.getAll().find(c => c.targetId === targetId);
567
665
  if (existing)
568
666
  return existing;
569
- const lock = readLock();
570
- const p = port || actualPort || lock?.port;
667
+ const lock = readLock(appName);
668
+ const p = port || (appName ? lock?.port : (actualPort || lock?.port));
571
669
  if (!p)
572
- throw new Error("[!] No active browser session. Run browser_go first.");
573
- const targets = await withCDPTimeout(CDP.List({ host: "127.0.0.1", port: p }), 5000, "connectToTab CDP.List");
574
- const t = targets.find(t => t.id === targetId);
575
- if (!t)
576
- throw new Error("Tab not found");
670
+ throw new Error(`[!] No active ${appName || "browser"} session. Run browser_go first.`);
671
+ let t = null;
672
+ try {
673
+ const targets = await withCDPTimeout(CDP.List({ host: "127.0.0.1", port: p }), 3000, "connectToTab CDP.List");
674
+ t = targets.find((item) => (item.id || item.targetId) === targetId);
675
+ }
676
+ catch { }
677
+ if (!t) {
678
+ try {
679
+ const metaSession = await withCDPTimeout(CDP({ host: "127.0.0.1", port: p }), 3000, "connectToTab meta CDP");
680
+ const { targetInfos } = await withCDPTimeout(metaSession.Target.getTargets(), 3000, "connectToTab meta getTargets");
681
+ await metaSession.close();
682
+ const ti = targetInfos.find(item => item.targetId === targetId);
683
+ if (ti) {
684
+ t = { id: ti.targetId, targetId: ti.targetId, url: ti.url, title: ti.title, type: ti.type };
685
+ }
686
+ }
687
+ catch { }
688
+ }
689
+ if (!t) {
690
+ t = { id: targetId, targetId, url: "", title: appName || "Tab", type: "page" };
691
+ }
577
692
  const session = await withCDPTimeout(CDP({ host: "127.0.0.1", port: p, target: targetId }), 5000, "connectToTab CDP");
578
693
  session.on("close", () => {
579
- logDebug("[Weavetab CDP] connectToTab session closed — unregistering.");
694
+ logDebug(`[Weavetab CDP] connectToTab session closed (${appName || "browser"}) — unregistering.`);
580
695
  // Cancel this tab's analytics interval so it doesn't outlive the session
581
696
  const iv = analyticsIntervals.get(targetId);
582
697
  if (iv) {
@@ -589,7 +704,7 @@ export async function connectToTab(targetId, port, agentId, browserContextId) {
589
704
  sessionRegistry.unregister(targetId);
590
705
  });
591
706
  session.on("error", (err) => {
592
- logDebug("[Weavetab CDP] connectToTab session error:", err?.message ?? err);
707
+ logDebug(`[Weavetab CDP] connectToTab session error (${appName || "browser"}):`, err?.message ?? err);
593
708
  });
594
709
  await Promise.all([
595
710
  session.Accessibility.enable(),
@@ -601,7 +716,7 @@ export async function connectToTab(targetId, port, agentId, browserContextId) {
601
716
  ]);
602
717
  await wireUiConfigBinding(session);
603
718
  const currentConfig = getCachedConfig();
604
- await initSession(session, targetId, p, currentConfig);
719
+ await initSession(session, targetId, p, currentConfig, !!appName);
605
720
  // NOTE: initSession() already installs a frameNavigated handler that handles
606
721
  // clearSnapshot, clearViewportCache, and ensureVisualLayer. Do NOT add a second
607
722
  // one here — it would double the CDP work on every navigation.
@@ -614,6 +729,8 @@ export async function connectToTab(targetId, port, agentId, browserContextId) {
614
729
  overlayInstalled: false,
615
730
  agentId: actualAgentId,
616
731
  browserContextId,
732
+ appName,
733
+ isElectron: !!appName,
617
734
  };
618
735
  sessionRegistry.register(targetId, client);
619
736
  return client;
@@ -4,6 +4,7 @@ export interface LockData {
4
4
  profileDir: string;
5
5
  browserPath: string;
6
6
  mode: "session" | "attach";
7
+ appName?: string;
7
8
  pending?: boolean;
8
9
  timestamp?: number;
9
10
  fingerprint?: {
@@ -12,17 +13,19 @@ export interface LockData {
12
13
  cdpVersion?: string;
13
14
  };
14
15
  }
15
- declare function acquireMutex(): Promise<() => Promise<void>>;
16
- export declare function writeLockSafe(data: LockData): Promise<void>;
17
- export declare function writeLock(data: LockData): Promise<void>;
18
- export declare function readLock(): LockData | null;
19
- export declare function clearLock(): void;
16
+ export declare function getLockFilePath(appName?: string): string;
17
+ export declare function getMutexFilePath(appName?: string): string;
18
+ declare function acquireMutex(appName?: string): Promise<() => Promise<void>>;
19
+ export declare function writeLockSafe(data: LockData, appName?: string): Promise<void>;
20
+ export declare function writeLock(data: LockData, appName?: string): Promise<void>;
21
+ export declare function readLock(appName?: string): LockData | null;
22
+ export declare function clearLock(appName?: string): void;
20
23
  /**
21
24
  * Safe variant: only clears the lock if it was written by THIS process (process.pid).
22
25
  * Prevents a second MCP server instance from wiping the first instance's browser lock
23
26
  * during its retry loop, which would allow a race condition where two browsers spawn.
24
27
  */
25
- export declare function clearLockIfOwned(): void;
28
+ export declare function clearLockIfOwned(appName?: string): void;
26
29
  export declare function isProcessRunning(pid: number): boolean;
27
30
  /**
28
31
  * Two-factor lock validation: PID alive AND port accepting TCP connections.
package/dist/cdp/lock.js CHANGED
@@ -10,49 +10,56 @@ import * as path from "node:path";
10
10
  import * as os from "node:os";
11
11
  import * as net from "node:net";
12
12
  import { lock } from "proper-lockfile";
13
+ import { sanitizeAppName } from "./profiles.js";
13
14
  const LOCK_DIR = path.join(os.homedir(), ".weavetab-system", "locks");
14
- const LOCK_FILE = path.join(LOCK_DIR, ".weavetab.lock");
15
- const MUTEX_FILE = path.join(LOCK_DIR, ".weavetab.lock.mutex");
16
- // ─── In-process serialization queue ──────────────────────────────────────────
17
- // When multiple MCP tool calls run in parallel inside the same Node process they
18
- // all race to acquireMutex() simultaneously. The OS-level proper-lockfile handles
19
- // cross-process contention, but within the same process concurrent callers share
20
- // the same lockfile handle and can trigger "Lock is already released" errors.
21
- // This queue serializes callers at the JS level before they touch the filesystem.
22
- let _inProcessMutexQueue = Promise.resolve();
23
- // Re-entrancy tracking. getOrConnect() acquires the mutex and then calls
24
- // launchWithBridge(), which internally calls writeLock() → acquireMutex()
25
- // again (nested). The serialization queue would block the nested call on the
26
- // outer slot, which only releases after the launch finishes — a self-deadlock.
27
- // Track how many regions this process currently holds and only touch the OS
28
- // file lock + queue slot on the outermost acquisition / release.
29
- let _heldDepth = 0;
30
- let _heldFileRelease = null;
31
- let _heldSlotResolve = null;
32
- async function acquireMutex() {
33
- // Re-entrant fast path: this process already holds the mutex.
34
- if (_heldDepth > 0) {
35
- _heldDepth++;
15
+ export function getLockFilePath(appName) {
16
+ if (appName && appName.trim()) {
17
+ return path.join(LOCK_DIR, `.weavetab.electron.${sanitizeAppName(appName)}.lock`);
18
+ }
19
+ return path.join(LOCK_DIR, ".weavetab.lock");
20
+ }
21
+ export function getMutexFilePath(appName) {
22
+ if (appName && appName.trim()) {
23
+ return path.join(LOCK_DIR, `.weavetab.electron.${sanitizeAppName(appName)}.lock.mutex`);
24
+ }
25
+ return path.join(LOCK_DIR, ".weavetab.lock.mutex");
26
+ }
27
+ // ─── In-process serialization queues per app/browser ──────────────────────────
28
+ const _inProcessQueues = new Map();
29
+ const _heldDepths = new Map();
30
+ const _heldFileReleases = new Map();
31
+ const _heldSlotResolves = new Map();
32
+ function getTargetKey(appName) {
33
+ return appName && appName.trim() ? `electron:${sanitizeAppName(appName)}` : "browser";
34
+ }
35
+ async function acquireMutex(appName) {
36
+ const key = getTargetKey(appName);
37
+ const currentDepth = _heldDepths.get(key) ?? 0;
38
+ // Re-entrant fast path: this process already holds the mutex for this target.
39
+ if (currentDepth > 0) {
40
+ _heldDepths.set(key, currentDepth + 1);
36
41
  return async () => {
37
- _heldDepth--;
42
+ const d = (_heldDepths.get(key) ?? 1) - 1;
43
+ _heldDepths.set(key, Math.max(0, d));
38
44
  };
39
45
  }
40
- // Enqueue this caller — it will only proceed once all prior callers finish.
46
+ // Enqueue this caller for this specific key
41
47
  let resolveSlot;
42
48
  const slot = new Promise(resolve => { resolveSlot = resolve; });
43
- const prevQueue = _inProcessMutexQueue;
44
- _inProcessMutexQueue = _inProcessMutexQueue.then(() => slot);
45
- await prevQueue; // Wait for all prior callers to fully release
49
+ const prevQueue = _inProcessQueues.get(key) ?? Promise.resolve();
50
+ _inProcessQueues.set(key, prevQueue.then(() => slot));
51
+ await prevQueue; // Wait for all prior callers for this key to fully release
46
52
  fs.mkdirSync(LOCK_DIR, { recursive: true });
47
- if (!fs.existsSync(MUTEX_FILE)) {
53
+ const mutexFile = getMutexFilePath(appName);
54
+ if (!fs.existsSync(mutexFile)) {
48
55
  try {
49
- fs.writeFileSync(MUTEX_FILE, String(process.pid), "utf-8");
56
+ fs.writeFileSync(mutexFile, String(process.pid), "utf-8");
50
57
  }
51
58
  catch { /* race/non-fatal */ }
52
59
  }
53
60
  let fileRelease = null;
54
61
  try {
55
- fileRelease = await lock(MUTEX_FILE, {
62
+ fileRelease = await lock(mutexFile, {
56
63
  retries: {
57
64
  retries: 25,
58
65
  minTimeout: 200,
@@ -62,28 +69,29 @@ async function acquireMutex() {
62
69
  });
63
70
  // Write current PID to mutex file for diagnostics/HUD
64
71
  try {
65
- fs.writeFileSync(MUTEX_FILE, String(process.pid), "utf-8");
72
+ fs.writeFileSync(mutexFile, String(process.pid), "utf-8");
66
73
  }
67
74
  catch { /* non-fatal */ }
68
75
  }
69
76
  catch (err) {
70
77
  resolveSlot(); // Unblock the queue even on failure
71
- throw new Error(`[Weavetab] Could not acquire launch mutex after retries: ${err?.message || err}`);
78
+ throw new Error(`[Weavetab] Could not acquire launch mutex for ${key} after retries: ${err?.message || err}`);
72
79
  }
73
80
  // Mark this process as the current holder so nested calls skip the queue.
74
- _heldDepth = 1;
75
- _heldFileRelease = fileRelease;
76
- _heldSlotResolve = resolveSlot;
81
+ _heldDepths.set(key, 1);
82
+ _heldFileReleases.set(key, fileRelease);
83
+ _heldSlotResolves.set(key, resolveSlot);
77
84
  // Return a release function that releases both the file lock and the in-process slot
78
85
  return async () => {
79
- _heldDepth--;
80
- if (_heldDepth > 0)
86
+ const d = (_heldDepths.get(key) ?? 1) - 1;
87
+ _heldDepths.set(key, Math.max(0, d));
88
+ if (d > 0)
81
89
  return; // nested regions still active
82
- _heldDepth = 0;
83
- const rel = _heldFileRelease;
84
- const slotResolve = _heldSlotResolve;
85
- _heldFileRelease = null;
86
- _heldSlotResolve = null;
90
+ _heldDepths.delete(key);
91
+ const rel = _heldFileReleases.get(key);
92
+ const slotResolve = _heldSlotResolves.get(key);
93
+ _heldFileReleases.delete(key);
94
+ _heldSlotResolves.delete(key);
87
95
  try {
88
96
  if (rel)
89
97
  await rel();
@@ -94,44 +102,49 @@ async function acquireMutex() {
94
102
  };
95
103
  }
96
104
  // ─── Public API ───────────────────────────────────────────────────────────────
97
- export async function writeLockSafe(data) {
98
- const release = await acquireMutex();
105
+ export async function writeLockSafe(data, appName) {
106
+ const targetApp = appName || data.appName;
107
+ const release = await acquireMutex(targetApp);
108
+ const lockFile = getLockFilePath(targetApp);
99
109
  try {
100
110
  fs.mkdirSync(LOCK_DIR, { recursive: true });
101
- fs.writeFileSync(LOCK_FILE, JSON.stringify(data, null, 2), "utf-8");
111
+ fs.writeFileSync(lockFile, JSON.stringify(data, null, 2), "utf-8");
102
112
  }
103
113
  finally {
104
114
  await release();
105
115
  }
106
116
  }
107
- export async function writeLock(data) {
108
- await writeLockSafe(data);
117
+ export async function writeLock(data, appName) {
118
+ await writeLockSafe(data, appName);
109
119
  }
110
- export function readLock() {
111
- if (!fs.existsSync(LOCK_FILE))
120
+ export function readLock(appName) {
121
+ const lockFile = getLockFilePath(appName);
122
+ if (!fs.existsSync(lockFile))
112
123
  return null;
113
124
  try {
114
- return JSON.parse(fs.readFileSync(LOCK_FILE, "utf-8"));
125
+ return JSON.parse(fs.readFileSync(lockFile, "utf-8"));
115
126
  }
116
127
  catch {
117
128
  return null;
118
129
  }
119
130
  }
120
- export function clearLock() {
131
+ export function clearLock(appName) {
132
+ const lockFile = getLockFilePath(appName);
133
+ const mutexFile = getMutexFilePath(appName);
121
134
  try {
122
- if (fs.existsSync(LOCK_FILE))
123
- fs.unlinkSync(LOCK_FILE);
135
+ if (fs.existsSync(lockFile))
136
+ fs.unlinkSync(lockFile);
124
137
  }
125
138
  catch (e) {
126
139
  logDebug(`[Weavetab] [!] Failed to clear lock file: ${e}`);
127
140
  }
128
141
  try {
129
- if (fs.existsSync(MUTEX_FILE))
130
- fs.unlinkSync(MUTEX_FILE);
142
+ if (fs.existsSync(mutexFile))
143
+ fs.unlinkSync(mutexFile);
131
144
  }
132
145
  catch { /* ignore */ }
133
146
  try {
134
- const lockDir = MUTEX_FILE + ".lock";
147
+ const lockDir = mutexFile + ".lock";
135
148
  if (fs.existsSync(lockDir))
136
149
  fs.rmSync(lockDir, { recursive: true, force: true });
137
150
  }
@@ -142,14 +155,14 @@ export function clearLock() {
142
155
  * Prevents a second MCP server instance from wiping the first instance's browser lock
143
156
  * during its retry loop, which would allow a race condition where two browsers spawn.
144
157
  */
145
- export function clearLockIfOwned() {
158
+ export function clearLockIfOwned(appName) {
146
159
  try {
147
- const lock = readLock();
160
+ const lock = readLock(appName);
148
161
  if (!lock || lock.pid !== process.pid) {
149
162
  logDebug(`[Weavetab] clearLockIfOwned: skipping — lock owned by PID ${lock?.pid ?? 'none'}, this PID is ${process.pid}`);
150
163
  return;
151
164
  }
152
- clearLock();
165
+ clearLock(appName);
153
166
  }
154
167
  catch (e) {
155
168
  logDebug(`[Weavetab] clearLockIfOwned error: ${e}`);
@@ -14,4 +14,5 @@ export declare function initOmniBroker(parentSession: CDP.Client, parentTargetId
14
14
  * Executes a CDP command across all known iframe contexts if it fails in the top-level context.
15
15
  */
16
16
  export declare function omniEvaluate(parentSession: CDP.Client, parentTargetId: string, expression: string, returnByValue?: boolean): Promise<any>;
17
+ export declare function getOmniSessions(parentTargetId: string): Map<string, CDP.Client> | undefined;
17
18
  export declare function cleanupOmniBroker(parentTargetId: string): void;