@weavetab/mcp 2.5.0-beta.1 → 2.5.0-beta.4

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 (156) hide show
  1. package/AGENTS.md +2 -1
  2. package/ARCHITECTURE.md +6 -6
  3. package/LICENSE +1 -1
  4. package/README.md +164 -802
  5. package/SECURITY.md +33 -22
  6. package/TROUBLESHOOTING.md +3 -3
  7. package/dist/cdp/bridge.d.ts +10 -0
  8. package/dist/cdp/bridge.js +285 -78
  9. package/dist/cdp/connector.d.ts +7 -3
  10. package/dist/cdp/connector.js +196 -75
  11. package/dist/cdp/helpers.d.ts +4 -0
  12. package/dist/cdp/helpers.js +181 -0
  13. package/dist/cdp/lock.d.ts +9 -6
  14. package/dist/cdp/lock.js +72 -59
  15. package/dist/cdp/omni_broker.d.ts +2 -1
  16. package/dist/cdp/omni_broker.js +13 -7
  17. package/dist/cdp/profiles.d.ts +10 -0
  18. package/dist/cdp/profiles.js +65 -0
  19. package/dist/cdp/walker.d.ts +25 -1
  20. package/dist/cdp/walker.js +354 -7
  21. package/dist/cli/plugin.d.ts +0 -2
  22. package/dist/cli/plugin.js +51 -696
  23. package/dist/cli/updater.d.ts +1 -1
  24. package/dist/cli/updater.js +27 -7
  25. package/dist/config/loader.d.ts +33 -6
  26. package/dist/config/loader.js +174 -22
  27. package/dist/desktop/discovery.d.ts +44 -0
  28. package/dist/desktop/discovery.js +364 -0
  29. package/dist/index.js +140 -64
  30. package/dist/intelligence/marks.d.ts +36 -0
  31. package/dist/intelligence/marks.js +30 -0
  32. package/dist/intelligence/patternCompiler.d.ts +118 -0
  33. package/dist/intelligence/patternCompiler.js +259 -0
  34. package/dist/intelligence/sessionMemory.d.ts +2 -0
  35. package/dist/intelligence/sessionMemory.js +18 -8
  36. package/dist/overlay/ghost.js +2 -2
  37. package/dist/overlay/injector.js +3 -5
  38. package/dist/plugin/extension_bridge.d.ts +40 -0
  39. package/dist/plugin/extension_bridge.js +158 -0
  40. package/dist/plugin/interface.d.ts +151 -15
  41. package/dist/plugin/loader.d.ts +19 -1
  42. package/dist/plugin/loader.js +331 -63
  43. package/dist/plugin/pipeline.d.ts +75 -0
  44. package/dist/plugin/pipeline.js +183 -0
  45. package/dist/plugin/registry.js +12 -1
  46. package/dist/plugin/storage.d.ts +51 -0
  47. package/dist/plugin/storage.js +164 -0
  48. package/dist/security/secrets.d.ts +1 -0
  49. package/dist/security/secrets.js +13 -0
  50. package/dist/sensors/actionability.js +1 -2
  51. package/dist/sensors/guard.js +4 -10
  52. package/dist/sensors/mutations.js +12 -0
  53. package/dist/sensors/operation_monitor.js +1 -0
  54. package/dist/server.d.ts +1 -1
  55. package/dist/server.js +600 -236
  56. package/dist/state/agent.d.ts +1 -0
  57. package/dist/state/agent.js +3 -0
  58. package/dist/state/delta.d.ts +12 -4
  59. package/dist/state/delta.js +67 -14
  60. package/dist/state/session.d.ts +8 -2
  61. package/dist/state/session.js +60 -13
  62. package/dist/tools/browser_detect.d.ts +16 -2
  63. package/dist/tools/browser_detect.js +54 -6
  64. package/dist/tools/burst.d.ts +17 -7
  65. package/dist/tools/burst.js +240 -83
  66. package/dist/tools/checkpoint.d.ts +38 -0
  67. package/dist/tools/checkpoint.js +271 -0
  68. package/dist/tools/click.d.ts +1 -0
  69. package/dist/tools/click.js +97 -98
  70. package/dist/tools/clipboard.js +31 -24
  71. package/dist/tools/design_extract.js +1 -1
  72. package/dist/tools/eval.js +1 -1
  73. package/dist/tools/fill.d.ts +20 -1
  74. package/dist/tools/fill.js +199 -40
  75. package/dist/tools/find.d.ts +16 -0
  76. package/dist/tools/find.js +67 -44
  77. package/dist/tools/github/index.d.ts +19 -4
  78. package/dist/tools/github/index.js +40 -4
  79. package/dist/tools/highlight.d.ts +6 -2
  80. package/dist/tools/highlight.js +158 -72
  81. package/dist/tools/inspect.js +1 -1
  82. package/dist/tools/key.js +65 -56
  83. package/dist/tools/navigate.d.ts +1 -1
  84. package/dist/tools/navigate.js +57 -44
  85. package/dist/tools/pdf.js +21 -13
  86. package/dist/tools/pointer.d.ts +18 -3
  87. package/dist/tools/pointer.js +89 -1
  88. package/dist/tools/read.d.ts +28 -8
  89. package/dist/tools/read.js +230 -56
  90. package/dist/tools/scrape.js +67 -13
  91. package/dist/tools/screenshot.js +21 -16
  92. package/dist/tools/scroll.js +6 -11
  93. package/dist/tools/select.js +8 -2
  94. package/dist/tools/snapshot.js +3 -34
  95. package/dist/tools/tabs.d.ts +10 -10
  96. package/dist/tools/tabs.js +55 -39
  97. package/dist/tools/thought.d.ts +2 -0
  98. package/dist/tools/thought.js +13 -14
  99. package/dist/tools/type.d.ts +2 -0
  100. package/dist/tools/type.js +109 -60
  101. package/dist/tools/type_secret.js +42 -76
  102. package/dist/tools/upload.js +1 -1
  103. package/dist/tools/video_compiler.d.ts +1 -1
  104. package/dist/tools/video_compiler.js +11 -12
  105. package/dist/version.d.ts +5 -0
  106. package/dist/version.js +5 -0
  107. package/docs/DOCUMENTATION.md +82 -36
  108. package/docs/PLUGINS.md +82 -499
  109. package/docs/TOOLS.md +51 -99
  110. package/docs/architecture/blind-injection.md +83 -0
  111. package/docs/architecture/dual-injection.md +32 -0
  112. package/docs/architecture/overview.md +49 -0
  113. package/docs/architecture/security.md +35 -0
  114. package/docs/architecture/transport.md +37 -0
  115. package/docs/captcha-stealth/captcha-solver.md +75 -0
  116. package/docs/captcha-stealth/stealth-physics.md +60 -0
  117. package/docs/cli/cli-commands.md +44 -0
  118. package/docs/cli/cli-flags.md +20 -0
  119. package/docs/cli/cli-introduction.md +20 -0
  120. package/docs/cli/cli-scripting.md +32 -0
  121. package/docs/cli/g-ide/ide-antigravity.md +24 -0
  122. package/docs/cli/g-ide/ide-claude-code.md +24 -0
  123. package/docs/cli/g-ide/ide-claude-desktop.md +32 -0
  124. package/docs/cli/g-ide/ide-cursor.md +31 -0
  125. package/docs/cli/g-ide/ide-generic.md +34 -0
  126. package/docs/cli/g-ide/ide-opencode.md +24 -0
  127. package/docs/cli/g-ide/ide-vscode-copilot.md +24 -0
  128. package/docs/cli/g-ide/ide-windsurf.md +24 -0
  129. package/docs/configuration/config-locations.md +24 -0
  130. package/docs/configuration/configuration.md +73 -0
  131. package/docs/configuration/env-variables.md +19 -0
  132. package/docs/configuration/folders.md +55 -0
  133. package/docs/desktop-automation/electron-apps.md +217 -0
  134. package/docs/get-started/installation.md +47 -0
  135. package/docs/get-started/introduction.md +60 -0
  136. package/docs/get-started/quickstart.md +54 -0
  137. package/docs/get-started/setup-wizard.md +26 -0
  138. package/docs/intelligence/site-profiles.md +151 -0
  139. package/docs/skills-ecosystem/skills-overview.md +35 -0
  140. package/docs/skills-ecosystem/skills-recipes.md +36 -0
  141. package/docs/tools/tools.md +631 -0
  142. package/docs/troubleshooting/trouble-browser.md +27 -0
  143. package/docs/troubleshooting/trouble-config.md +20 -0
  144. package/docs/troubleshooting/trouble-loop.md +31 -0
  145. package/ext-dist/background.js +10 -62
  146. package/ext-dist/content.js +333 -921
  147. package/ext-dist/hud/hud.js +9 -9
  148. package/ext-dist/manifest.json +2 -6
  149. package/ext-dist/styles.css +6 -1
  150. package/mcp.json +108 -72
  151. package/package.json +14 -9
  152. package/server.json +3 -3
  153. package/docs/DEV_MODE.md +0 -68
  154. package/docs/dev_plugin.md +0 -310
  155. package/ext-dist/sidepanel/sidepanel.js +0 -25
  156. package/ext-dist/sidepanel.html +0 -547
package/dist/server.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Weavetab — server.ts
3
- * MCP server: 43 browser automation tools with security, rate limiting, and visual overlay.
3
+ * MCP server: 44 browser automation tools with security, rate limiting, and visual overlay.
4
4
  * Weavetab by fy2ne
5
5
  */
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { z } from "zod";
9
+ import { WEAVETAB_VERSION } from "./version.js";
9
10
  import { getOrConnect } from "./cdp/connector.js";
10
11
  import { checkRateLimit } from "./security/ratelimit.js";
11
12
  import { checkDomain, isToolAllowedInMode } from "./security/blacklist.js";
@@ -44,31 +45,32 @@ import { browserExtractDesign } from "./tools/design_extract.js";
44
45
  import { browserAutomation } from "./tools/automation.js";
45
46
  import { browserRecording } from "./tools/recording.js";
46
47
  import { browserPerformance } from "./tools/performance.js";
47
- import { compileTraceToMacro } from "./tools/macro_compiler.js";
48
48
  import { detectBrowsers } from "./tools/browser_detect.js";
49
49
  import { browserThought } from "./tools/thought.js";
50
50
  import { browserCaptcha } from "./tools/captcha.js";
51
51
  import { browserEmulate } from "./tools/emulation.js";
52
+ import { browserCheckpoint } from "./tools/checkpoint.js";
52
53
  import { getActiveEngine } from "./cdp/connector.js";
53
54
  import { resetLoopCounter } from "./intelligence/trail.js";
54
- import { discoverPlugins } from "./plugin/loader.js";
55
+ import { getSiteProfile, learnPattern, recordPatternExecution, recordQuirk, getPatternReliability, formatSiteProfileHint } from "./intelligence/patternCompiler.js";
56
+ import { discoverPlugins, listInstalledPlugins, loadPluginByName } from "./plugin/loader.js";
57
+ import { hookPipeline } from "./plugin/pipeline.js";
55
58
  import { agentAction } from "./sensors/pacing.js";
56
- import { getCachedConfig } from "./config/loader.js";
59
+ import { getCachedConfig, getElectronApps } from "./config/loader.js";
57
60
  import { agentState } from "./state/agent.js";
61
+ import { SessionSecretTracker } from "./security/secrets.js";
58
62
  import { sessionRegistry } from "./state/session.js";
59
63
  // GitHub tools
60
- import { githubAnalyze } from "./tools/github/analyze.js";
61
- import { githubRead } from "./tools/github/read.js";
62
- import { githubIssues } from "./tools/github/issues.js";
63
- import { githubGetPr } from "./tools/github/pr.js";
64
+ import { browserGithub } from "./tools/github/index.js";
64
65
  // Office document tools
65
66
  import { browserOfficeParse, browserOfficeList } from "./tools/office/index.js";
67
+ const activeLoadedPlugins = new Map();
66
68
  const ROLE_TOOL_ALLOW = {
67
69
  admin: new Set(["*"]),
68
70
  operator: new Set(["*"]),
69
- viewer: new Set(["browser_map", "browser_find", "browser_scrape", "browser_console", "browser_eval", "browser_inspect", "browser_extract_design", "browser_snapshot", "browser_screenshot", "browser_pdf", "browser_cookies", "browser_tabs", "browser_wait"]),
71
+ viewer: new Set(["browser_map", "browser_find", "browser_scrape", "browser_console", "browser_eval", "browser_inspect", "browser_extract_design", "browser_snapshot", "browser_screenshot", "browser_pdf", "browser_cookies", "browser_tabs", "browser_wait", "browser_pattern_learn", "browser_github"]),
70
72
  automation: new Set(["*"]), // Allowed all except interactive tools (enforced in isToolAllowedForRole)
71
- system: new Set(["browser_thoughts", "browser_reset_loop_counter", "browser_detect", "browser_performance"]),
73
+ system: new Set(["browser_thoughts", "browser_detect", "browser_performance", "browser_checkpoint", "browser_pattern_learn"]),
72
74
  };
73
75
  function isToolAllowedForRole(role, toolName) {
74
76
  if (role === "automation" && toolName === "browser_thoughts") {
@@ -81,7 +83,7 @@ function isToolAllowedForRole(role, toolName) {
81
83
  return { allowed: true };
82
84
  if (allowed.has(toolName))
83
85
  return { allowed: true };
84
- return { allowed: false, reason: `Role '${role}' does not have permission to use '${toolName}'. ${role === "viewer" ? "Viewer role only permits read-only tools (map/find/scrape/console/eval/inspect/snapshot/pdf/cookies/github_*)." : ""}` };
86
+ return { allowed: false, reason: `Role '${role}' does not have permission to use '${toolName}'. ${role === "viewer" ? "Viewer role only permits read-only tools (map/find/scrape/console/eval/inspect/snapshot/pdf/cookies/browser_github)." : ""}` };
85
87
  }
86
88
  /** Push a rich log card to every active browser session's overlay. Only shown when devMode is on. */
87
89
  async function pushRichLog(agentId, toolName, title, details, status, devMode = false) {
@@ -104,7 +106,69 @@ function withTimeoutGuard(fn, timeoutMs, label) {
104
106
  });
105
107
  return Promise.race([fn(), timeoutPromise]).finally(() => clearTimeout(timer));
106
108
  }
107
- async function withSecurity(config, toolName, handler, isOfficial = false) {
109
+ function inferTargetApp(requestedApp, url, tabId) {
110
+ if (requestedApp) {
111
+ const lower = requestedApp.toLowerCase().trim();
112
+ if (lower === "browser" || lower === "chrome" || lower === "chromium") {
113
+ sessionRegistry.setLastActiveApp(null);
114
+ return undefined;
115
+ }
116
+ sessionRegistry.setLastActiveApp(lower);
117
+ return lower;
118
+ }
119
+ // 1. Infer from tabId if provided
120
+ if (tabId) {
121
+ const registered = sessionRegistry.findClientByTargetId(tabId);
122
+ if (registered && registered.isElectron && registered.appName) {
123
+ sessionRegistry.setLastActiveApp(registered.appName);
124
+ return registered.appName;
125
+ }
126
+ }
127
+ // 2. Infer from URL domain if provided
128
+ if (url) {
129
+ const config = getCachedConfig();
130
+ const apps = getElectronApps(config);
131
+ const u = url.toLowerCase();
132
+ for (const appKey of Object.keys(apps)) {
133
+ if (appKey === "discord" && (u.includes("discord.com") || u.startsWith("discord:"))) {
134
+ logAction("target_router", "auto_route", `Auto-routing URL to Electron app "${appKey}"`);
135
+ sessionRegistry.setLastActiveApp(appKey);
136
+ return appKey;
137
+ }
138
+ if (appKey === "slack" && (u.includes("slack.com") || u.startsWith("slack:"))) {
139
+ logAction("target_router", "auto_route", `Auto-routing URL to Electron app "${appKey}"`);
140
+ sessionRegistry.setLastActiveApp(appKey);
141
+ return appKey;
142
+ }
143
+ if (appKey === "linear" && (u.includes("linear.app") || u.startsWith("linear:"))) {
144
+ logAction("target_router", "auto_route", `Auto-routing URL to Electron app "${appKey}"`);
145
+ sessionRegistry.setLastActiveApp(appKey);
146
+ return appKey;
147
+ }
148
+ if (appKey === "notion" && (u.includes("notion.so") || u.startsWith("notion:"))) {
149
+ logAction("target_router", "auto_route", `Auto-routing URL to Electron app "${appKey}"`);
150
+ sessionRegistry.setLastActiveApp(appKey);
151
+ return appKey;
152
+ }
153
+ }
154
+ }
155
+ // 3. Fall back to sticky active session if no web browser is running
156
+ const hasActiveBrowser = sessionRegistry.isBrowserSessionActive();
157
+ if (!hasActiveBrowser) {
158
+ const lastApp = sessionRegistry.getLastActiveApp();
159
+ if (lastApp) {
160
+ return lastApp;
161
+ }
162
+ const allElectrons = sessionRegistry.getActiveElectronClients();
163
+ if (allElectrons.length === 1 && allElectrons[0]?.appName) {
164
+ const soleApp = allElectrons[0].appName;
165
+ sessionRegistry.setLastActiveApp(soleApp);
166
+ return soleApp;
167
+ }
168
+ }
169
+ return undefined;
170
+ }
171
+ async function withSecurity(config, toolName, handler, isOfficial = false, targetApp) {
108
172
  const liveConfig = getCachedConfig();
109
173
  const rl = checkRateLimit(liveConfig);
110
174
  if (!rl.allowed) {
@@ -117,7 +181,8 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
117
181
  throw new Error(`[!] ${roleToolCheck.reason}`);
118
182
  }
119
183
  }
120
- const { session } = await withTimeoutGuard(() => getOrConnect(liveConfig), 15000, `CDP Connection (${toolName})`);
184
+ const resolvedTargetApp = inferTargetApp(targetApp);
185
+ const { session } = await withTimeoutGuard(() => getOrConnect(liveConfig, resolvedTargetApp), 15000, `CDP Connection (${toolName}${resolvedTargetApp ? `:${resolvedTargetApp}` : ''})`);
121
186
  agentState.setSession(session);
122
187
  global.__wt_live_config = liveConfig;
123
188
  try {
@@ -141,8 +206,8 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
141
206
  }
142
207
  if (!isOfficial) {
143
208
  // Phase 7c: Domain policy check for read-only / deny / audit-only modes
144
- const activeClient = sessionRegistry.getActive(liveConfig.agentId);
145
- if (activeClient?.url) {
209
+ const activeClient = sessionRegistry.getActive(liveConfig.agentId, targetApp);
210
+ if (activeClient?.url && !activeClient.isElectron) {
146
211
  const domainCheck = checkDomain(activeClient.url, liveConfig);
147
212
  if (!domainCheck.allowed) {
148
213
  throw new Error(`[!] ${domainCheck.reason}`);
@@ -198,7 +263,7 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
198
263
  const result = await withTimeoutGuard(() => handler(session, liveConfig), 22000, toolName);
199
264
  agentState.reportToolSuccess();
200
265
  const ms = Date.now() - t0;
201
- const activeClient = sessionRegistry.getActive(liveConfig.agentId);
266
+ const activeClient = sessionRegistry.getActive(liveConfig.agentId, targetApp);
202
267
  logActionStructured({
203
268
  timestamp: new Date(t0).toISOString(),
204
269
  sessionId: activeClient?.targetId,
@@ -265,6 +330,13 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
265
330
  { type: "image", data: base64Data, mimeType },
266
331
  ];
267
332
  }
333
+ else if (resultAny && typeof resultAny === 'object' && Array.isArray(resultAny.content)) {
334
+ // Passthrough if already conforming McpToolResult (prevents double JSON wrapping)
335
+ content = resultAny.content;
336
+ }
337
+ else if (typeof result === 'string') {
338
+ content = [{ type: "text", text: result }];
339
+ }
268
340
  else {
269
341
  content = [{ type: "text", text: JSON.stringify(result) }];
270
342
  }
@@ -322,7 +394,7 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
322
394
  const ms = Date.now() - t0;
323
395
  const errMsg = err instanceof Error ? err.message : String(err);
324
396
  agentState.reportToolError();
325
- const activeClient = sessionRegistry.getActive(liveConfig.agentId);
397
+ const activeClient = sessionRegistry.getActive(liveConfig.agentId, targetApp);
326
398
  logActionStructured({
327
399
  timestamp: new Date(t0).toISOString(),
328
400
  sessionId: activeClient?.targetId,
@@ -340,13 +412,39 @@ async function withSecurity(config, toolName, handler, isOfficial = false) {
340
412
  }
341
413
  }
342
414
  function wrapError(err) {
343
- const message = err instanceof Error ? err.message : String(err);
415
+ let message = err instanceof Error ? err.message : String(err);
416
+ message = SessionSecretTracker.redactAll(message);
344
417
  logAction("ERROR", "server", message);
345
418
  return { content: [{ type: "text", text: message }], isError: true };
346
419
  }
347
420
  export async function startServer(config, transportMode = "stdio", wsPort = 3000) {
348
- const server = new McpServer({ name: "Weavetab MCP", version: "3.0.0-beta" });
349
- // Initialize agent state manager
421
+ const server = new McpServer({ name: "Weavetab MCP", version: WEAVETAB_VERSION });
422
+ // Intercept server.tool to inject global plugin middleware hooks (onBeforeAction, onAfterAction)
423
+ const rawTool = server.tool.bind(server);
424
+ server.tool = function (name, ...rest) {
425
+ const rawHandler = rest[rest.length - 1];
426
+ if (typeof rawHandler === "function") {
427
+ rest[rest.length - 1] = async (args, extra) => {
428
+ try {
429
+ const session = agentState.getSession() ?? sessionRegistry.getActive(config.agentId)?.session;
430
+ const dispatchedArgs = await hookPipeline.dispatchBeforeToolCall(name, args || {}, session);
431
+ const result = await rawHandler(dispatchedArgs, extra);
432
+ const finalResult = await hookPipeline.dispatchAfterToolCall(name, dispatchedArgs, result, session);
433
+ return finalResult;
434
+ }
435
+ catch (err) {
436
+ if (err?.isBlocked || err?.__blocked) {
437
+ return {
438
+ isError: true,
439
+ content: [{ type: "text", text: `[!] Action '${name}' blocked by plugin: ${err.message}` }],
440
+ };
441
+ }
442
+ throw err;
443
+ }
444
+ };
445
+ }
446
+ return rawTool(name, ...rest);
447
+ };
350
448
  agentState.setIdleTimeout(config.idleTimeout ?? 60000);
351
449
  agentState.setCallbacks({
352
450
  onStateChange: async (session, state, word) => {
@@ -360,35 +458,40 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
360
458
  }
361
459
  },
362
460
  onUpdateTabTitle: async (_session, _state) => {
363
- // Handled robustly via Weavetab extension background & content scripts
461
+ // handled via extension background & content scripts
364
462
  },
365
463
  });
366
464
  // ── 1. browser_map ─────────────────────────────────────────────────────────
367
- server.tool("browser_map", "Snapshot interactive DOM with volatile ref IDs that invalidate after DOM mutations. For complex SPAs (e.g. GitHub dashboards), use `scope` or `query` to reduce tokens. For forms/pagination/sequential loops, ALWAYS use `delta: true` to save 97% tokens.", {
465
+ server.tool("browser_map", "Snapshot interactive DOM with volatile ref IDs that invalidate after DOM mutations. For complex SPAs (e.g. GitHub dashboards), use `scope` or `query` to reduce tokens. For forms/pagination/sequential loops, ALWAYS use `delta: true` to save 97% tokens. For canvas/WebGL/Figma pages where the DOM has < 5 interactive elements, use `visual: \"auto\"` to get a numbered screenshot — then use browser_click({ mark: N }) to interact.", {
368
466
  full: z.boolean().optional().describe("Set to true to return a full markdown table (heavier context) instead of the lite list."),
369
- scope: z.enum(["navigation", "main_content", "sidebar", "form", "modal", "utility"]).optional().describe("Restrict parsing to a semantic page area to save tokens. Crucial for complex dashboards."),
370
- query: z.string().optional().describe("Filter returned elements by label or role substring. Crucial for complex dashboards."),
467
+ scope: z.enum(["navigation", "main_content", "sidebar", "form", "modal", "utility"]).optional().describe("Restrict parsing to a semantic page area to save tokens. Useful on dense dashboards."),
468
+ query: z.string().optional().describe("Filter returned elements by label or role substring. Useful on dense dashboards."),
371
469
  delta: z.boolean().optional().describe("Diff mode: return ONLY elements that changed since the last browser_map call (added, removed, or modified). Diffs across time — distinct from prune. Cuts prompt tokens by up to 97% on consecutive calls."),
372
470
  ax_tree: z.boolean().optional().describe("Bypass the extension entirely and read raw Chrome Accessibility Tree. Use this if Shadow DOMs hide elements."),
373
471
  tab_id: z.string().optional().describe("Read a specific background tab instead of the active one."),
472
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
374
473
  show_overlays: z.boolean().optional().describe("Default false. Debug only — draws colored element borders via extension (ugly). The blue edge glow is the standard visual indicator, shown automatically during reading states. Only enable this for debugging element boundaries."),
375
- force: z.boolean().optional().describe("Force a full DOM map rebuild even if loop detection says nothing changed. Bypasses the map skip guard."),
474
+ force: z.boolean().optional().describe("Force a full DOM map rebuild even if loop detection says nothing changed. Bypasses the map skip guard and resets loop strikes."),
376
475
  prune: z.boolean().optional().describe("Semantic DOM pruning: compress the current snapshot before returning. Drops hidden elements with no interactive descendants and not referenced by any toggle trigger (aria-controls etc.). Replaces SVG icons with [SVG Icon: label] tokens. Distinct from delta:true — delta diffs across time, prune compresses this snapshot once. Combine both for maximum token efficiency."),
476
+ visual: z.union([z.boolean(), z.literal("auto")]).optional().describe("Set-of-Marks visual grounding. true = always inject numbered badges + return annotated screenshot. \"auto\" = only activate when DOM interactive count < 5 (canvas/WebGL/Figma pages). After receiving marks, use browser_click({ mark: N }) to interact with numbered elements."),
377
477
  }, async (args) => {
378
478
  try {
379
479
  return await withSecurity(config, "browser_map", async (session, liveConfig) => {
380
480
  let targetSession = session;
381
481
  let targetId = "default";
382
482
  const { sessionRegistry } = await import("./state/session.js");
383
- const active = sessionRegistry.getActive(liveConfig.agentId);
483
+ const active = sessionRegistry.getActive(liveConfig.agentId, args.app);
384
484
  if (active)
385
485
  targetId = active.targetId;
386
486
  if (args.tab_id) {
387
487
  const { connectToTab } = await import("./cdp/connector.js");
388
- const client = await connectToTab(args.tab_id);
488
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, args.app);
389
489
  targetSession = client.session;
390
490
  targetId = args.tab_id;
391
491
  }
492
+ if (args.force) {
493
+ resetLoopCounter("map_force");
494
+ }
392
495
  // Structural block — map after no-change click/type is a pure wasted round-trip
393
496
  // Skip the skip guard if the user explicitly requests force or delta diff
394
497
  if (!args.force && !args.delta) {
@@ -408,99 +511,129 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
408
511
  }
409
512
  }
410
513
  const result = await agentAction(targetSession, 'reading', () => browserMap(targetSession, args, targetId, liveConfig), 'browser_map');
514
+ // Handle visual grounding result (text + annotated image)
515
+ if (result && typeof result === 'object' && 'text' in result) {
516
+ const vr = result;
517
+ const content = [
518
+ { type: "text", text: vr.text },
519
+ ];
520
+ if (vr.imageBase64) {
521
+ content.push({ type: "image", data: vr.imageBase64, mimeType: "image/jpeg" });
522
+ }
523
+ return { content };
524
+ }
411
525
  return { content: [{ type: "text", text: result }] };
412
- });
526
+ }, false, args.app);
413
527
  }
414
528
  catch (e) {
415
529
  return wrapError(e);
416
530
  }
417
531
  });
418
532
  // ── 2. browser_navigate ────────────────────────────────────────────────────
419
- server.tool("browser_navigate", "Navigate to a URL.", {
420
- url: z.string().describe("Absolute URL to navigate to (must include https://)."),
533
+ server.tool("browser_navigate", "Navigate to a URL or launch/focus a configured Electron app.", {
534
+ url: z.string().optional().describe("Absolute URL to navigate to (must include https:// or app URL). Optional if 'app' is specified."),
535
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron' (e.g. 'example' or 'my-dev-app'). When specified, targets or launches the Electron app."),
421
536
  force: z.boolean().optional().describe("If true, overwrites current tab even if it's protected (anchor tab). Otherwise opens a new tab automatically."),
422
537
  tab_id: z.string().optional().describe("Target a specific tab ID for navigation instead of the active tab.")
423
538
  }, async (args) => {
424
539
  try {
540
+ if (!args.url && !args.app) {
541
+ throw new Error("Either 'url' or 'app' must be provided to browser_navigate.");
542
+ }
543
+ const resolvedApp = inferTargetApp(args.app, args.url, args.tab_id);
425
544
  return await withSecurity(config, "browser_navigate", async (session, liveConfig) => {
426
545
  let targetSession = session;
427
546
  let targetId = "default";
428
547
  if (args.tab_id) {
429
548
  const { connectToTab } = await import("./cdp/connector.js");
430
- const client = await connectToTab(args.tab_id);
549
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, resolvedApp);
431
550
  targetSession = client.session;
432
551
  targetId = args.tab_id;
433
552
  }
434
- return await agentAction(targetSession, 'navigating', () => weaveNavigate(targetSession, args.url, liveConfig, targetId, args.force), 'browser_navigate');
435
- });
553
+ if (args.url) {
554
+ const navResult = await agentAction(targetSession, 'navigating', () => weaveNavigate(targetSession, args.url, liveConfig, targetId, args.force), 'browser_navigate');
555
+ await hookPipeline.dispatchPageNavigate(args.url, targetSession);
556
+ return navResult;
557
+ }
558
+ return { success: true, app: resolvedApp, message: `Connected to Electron app "${resolvedApp}".` };
559
+ }, false, resolvedApp);
436
560
  }
437
561
  catch (e) {
438
562
  return wrapError(e);
439
563
  }
440
564
  });
441
565
  // ── 3. browser_click ───────────────────────────────────────────────────────
442
- server.tool("browser_click", "Click a DOM element. Use browser_canvas for charts/canvas. If stuck_warning=true, stop and use browser_map.", {
566
+ server.tool("browser_click", "Click a DOM element. Use browser_canvas for charts/canvas. If stuck_warning=true, stop and use browser_map. Pass mark: N to click a numbered element from browser_map({ visual: true/\"auto\" }) without any DOM re-scan.", {
443
567
  id: z.string().optional().describe("Ref ID from browser_map (e.g. 'w:42'). Primary targeting method. DO NOT SPAM. If stuck_warning is returned, stop retrying and call browser_map first."),
568
+ ref: z.string().optional().describe("Alias for id (e.g. 'w:42')."),
444
569
  selector: z.string().optional().describe("CSS selector of the element to click."),
445
570
  target: z.string().optional().describe("CSS selector or query string. Alias for selector."),
446
571
  label: z.string().optional().describe("Visible text or aria-label to click."),
447
572
  intent: z.string().optional().describe("Semantic description of the action (e.g. 'submit login form'). WeaveTab checks learned memory first — if a high-confidence selector is found it clicks immediately without DOM resolution. Falls through to normal resolution on a miss; does not error."),
573
+ mark: z.number().int().positive().optional().describe("Mark number from browser_map({ visual: true/\"auto\" }). Clicks the center of the numbered badge seen in the annotated screenshot. Zero DOM walk — instant coordinate dispatch. Use when the page is canvas/WebGL and DOM refs are unavailable."),
448
574
  backendNodeId: z.number().optional().describe("Raw CDP backendNodeId for direct hardware targeting."),
449
575
  x: z.number().optional().describe("Raw X coordinate. Use for precise positional clicks when element ID is not available."),
450
576
  y: z.number().optional().describe("Raw Y coordinate. Use for precise positional clicks when element ID is not available."),
451
577
  include_map: z.boolean().optional().describe("Set true to include a full updated page_map in the response. Default false — omit when you already have ref IDs or don't need a DOM refresh."),
452
578
  tab_id: z.string().optional().describe("Execute in a specific tab instead of active."),
579
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
453
580
  }, async (args) => {
454
581
  try {
455
582
  return await withSecurity(config, "browser_click", async (session, liveConfig) => {
456
583
  let targetSession = session;
457
584
  if (args.tab_id) {
458
585
  const { connectToTab } = await import("./cdp/connector.js");
459
- const client = await connectToTab(args.tab_id);
586
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, args.app);
460
587
  targetSession = client.session;
461
588
  }
462
589
  const normalizedArgs = { ...args };
590
+ if (args.ref && !normalizedArgs.id)
591
+ normalizedArgs.id = args.ref;
463
592
  if (args.selector && !normalizedArgs.id)
464
593
  normalizedArgs.id = args.selector;
465
594
  if (args.target && !normalizedArgs.id)
466
595
  normalizedArgs.id = args.target;
467
596
  return await agentAction(targetSession, 'clicking', () => weaveClick(targetSession, normalizedArgs, liveConfig), 'browser_click');
468
- });
597
+ }, false, args.app);
469
598
  }
470
599
  catch (e) {
471
600
  return wrapError(e);
472
601
  }
473
602
  });
474
603
  // ── 4. browser_type ────────────────────────────────────────────────────────
475
- server.tool("browser_type", "Type text into a single input field (use browser_fill for smart multi-field forms). If NOT_EDITABLE, fallback to click + browser_key. Use browser_map if stuck.", {
476
- id: z.string().optional().describe("Ref ID from browser_map. Primary targeting — always prefer this over label when you have a ref ID."),
477
- selector: z.string().optional().describe("CSS selector of the input field. Use when you know the exact selector."),
604
+ server.tool("browser_type", "Type text into an input field or currently focused element. If id/selector/label is omitted, automatically types directly into document.activeElement without needing a redundant click or browser_map. If target is already focused, skips click to prevent unfocus bugs.", {
605
+ id: z.string().optional().describe("Ref ID from browser_map (e.g. 'w:42'). If omitted, types directly into the currently focused element or single page input."),
606
+ ref: z.string().optional().describe("Alias for id (e.g. 'w:42')."),
607
+ selector: z.string().optional().describe("CSS selector of the input field."),
478
608
  target: z.string().optional().describe("CSS selector or query string. Alias for selector."),
479
609
  label: z.string().optional().describe("Field label or placeholder text. Used for fuzzy matching."),
480
610
  intent: z.string().optional().describe("Semantic description of the field (e.g. 'search box', 'username field'). Checked against learned memory first."),
481
- x: z.number().optional().describe("Raw X coordinate to focus before typing. Use as last resort when no id/selector/label is available."),
482
- y: z.number().optional().describe("Raw Y coordinate to focus before typing. Use as last resort when no id/selector/label is available."),
483
- text: z.string().describe("Text to type. If a NOT_EDITABLE error is returned: (1) use browser_click on the field, then (2) use browser_key with the text as individual keypresses."),
484
- clearFirst: z.boolean().optional().describe("Wipe existing field content before typing. May break React/Vue controlled inputs — avoid unless field already has wrong text in it."),
485
- paste: z.boolean().optional().describe("Instant paste mode via clipboard. Auto-enabled for text longer than 8 chars. Best for long URLs or multi-word strings."),
611
+ x: z.number().optional().describe("Raw X coordinate to focus before typing."),
612
+ y: z.number().optional().describe("Raw Y coordinate to focus before typing."),
613
+ text: z.string().describe("Text to type into the field."),
614
+ clearFirst: z.boolean().optional().describe("Wipe existing field content before typing."),
615
+ paste: z.boolean().optional().describe("Instant paste mode via clipboard. Auto-enabled for text longer than 8 chars."),
486
616
  include_map: z.boolean().optional().describe("Set true to include a full updated page_map in the response. Default false."),
487
617
  tab_id: z.string().optional().describe("Execute in a specific tab instead of active."),
618
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
488
619
  }, async (args) => {
489
620
  try {
490
621
  return await withSecurity(config, "browser_type", async (session, liveConfig) => {
491
622
  let targetSession = session;
492
623
  if (args.tab_id) {
493
624
  const { connectToTab } = await import("./cdp/connector.js");
494
- const client = await connectToTab(args.tab_id);
625
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, args.app);
495
626
  targetSession = client.session;
496
627
  }
497
628
  const normalizedArgs = { ...args };
629
+ if (args.ref && !normalizedArgs.id)
630
+ normalizedArgs.id = args.ref;
498
631
  if (args.selector && !normalizedArgs.id)
499
632
  normalizedArgs.id = args.selector;
500
633
  if (args.target && !normalizedArgs.id)
501
634
  normalizedArgs.id = args.target;
502
635
  return await agentAction(targetSession, 'typing', () => weaveType(targetSession, normalizedArgs, liveConfig), 'browser_type');
503
- });
636
+ }, false, args.app);
504
637
  }
505
638
  catch (e) {
506
639
  return wrapError(e);
@@ -509,13 +642,18 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
509
642
  // ── 4b. browser_type_secret ────────────────────────────────────────────────
510
643
  server.tool("browser_type_secret", "Inject a secret from an environment variable.", {
511
644
  id: z.string().optional().describe("Ref ID from browser_map."),
645
+ ref: z.string().optional().describe("Alias for id (e.g. 'w:42')."),
512
646
  target: z.string().optional().describe("Alternative target input element identifier or CSS selector."),
513
647
  envKey: z.string().describe("The environment variable key containing the secret."),
648
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
514
649
  }, async (args) => {
515
650
  try {
516
651
  return await withSecurity(config, "browser_type_secret", async (session, liveConfig) => {
517
- return await agentAction(session, 'typing', () => weaveTypeSecret(session, args, liveConfig), 'browser_type_secret');
518
- });
652
+ const normalizedArgs = { ...args };
653
+ if (args.ref && !normalizedArgs.id)
654
+ normalizedArgs.id = args.ref;
655
+ return await agentAction(session, 'typing', () => weaveTypeSecret(session, normalizedArgs, liveConfig), 'browser_type_secret');
656
+ }, false, args.app);
519
657
  }
520
658
  catch (e) {
521
659
  return wrapError(e);
@@ -526,18 +664,19 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
526
664
  key: z.string().describe("Key name: Enter, Escape, Tab, ArrowDown, F5, etc. DO NOT SPAM. If action fails or loops, STOP calling this tool and use browser_map to re-evaluate the page state."),
527
665
  modifiers: z.array(z.enum(["Alt", "Ctrl", "Meta", "Shift"])).optional().describe("Array of modifier keys to hold during the press."),
528
666
  tab_id: z.string().optional().describe("Execute in a specific tab instead of active."),
667
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
529
668
  }, async (args) => {
530
- const { key, modifiers, tab_id } = args;
669
+ const { key, modifiers, tab_id, app } = args;
531
670
  try {
532
671
  return await withSecurity(config, "browser_key", async (session, liveConfig) => {
533
672
  let targetSession = session;
534
673
  if (tab_id) {
535
674
  const { connectToTab } = await import("./cdp/connector.js");
536
- const client = await connectToTab(tab_id);
675
+ const client = await connectToTab(tab_id, undefined, undefined, undefined, app);
537
676
  targetSession = client.session;
538
677
  }
539
678
  return await weaveKey(targetSession, { key, modifiers }, liveConfig);
540
- });
679
+ }, false, app);
541
680
  }
542
681
  catch (e) {
543
682
  return wrapError(e);
@@ -546,39 +685,48 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
546
685
  // ── 6. browser_scroll ──────────────────────────────────────────────────────
547
686
  server.tool("browser_scroll", "Scroll the page.", {
548
687
  id: z.string().optional().describe("Ref ID to scroll into view or scroll inside of. DO NOT SPAM. If action fails or loops, STOP calling this tool and use browser_map to re-evaluate the page state."),
688
+ ref: z.string().optional().describe("Alias for id (e.g. 'w:42')."),
549
689
  selector: z.string().optional().describe("CSS Selector of the element to scroll into view."),
550
690
  target: z.string().optional().describe("Alternative element identifier or query to scroll into view."),
551
691
  label: z.string().optional().describe("Element label to scroll into view."),
552
692
  direction: z.enum(["up", "down", "left", "right"]).describe("Scroll direction."),
553
693
  distance: z.number().optional().describe("Pixels to scroll (default 400)."),
554
694
  tab_id: z.string().optional().describe("Execute in a specific tab instead of active. DO NOT SPAM. If action fails or loops, STOP calling this tool and use browser_map to re-evaluate the page state."),
695
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
555
696
  }, async (args) => {
556
697
  try {
557
698
  return await withSecurity(config, "browser_scroll", async (session, liveConfig) => {
558
699
  let targetSession = session;
559
700
  if (args.tab_id) {
560
701
  const { connectToTab } = await import("./cdp/connector.js");
561
- const client = await connectToTab(args.tab_id);
702
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, args.app);
562
703
  targetSession = client.session;
563
704
  }
564
705
  const normalizedArgs = { ...args };
706
+ if (args.ref && !normalizedArgs.id)
707
+ normalizedArgs.id = args.ref;
565
708
  if (args.selector && !normalizedArgs.id)
566
709
  normalizedArgs.id = args.selector;
567
710
  if (args.target && !normalizedArgs.id)
568
711
  normalizedArgs.id = args.target;
569
712
  return await browserScroll(targetSession, normalizedArgs, liveConfig);
570
- });
713
+ }, false, args.app);
571
714
  }
572
715
  catch (e) {
573
716
  return wrapError(e);
574
717
  }
575
718
  });
576
719
  // ── 7. browser_dialog ──────────────────────────────────────────────────────
577
- server.tool("browser_dialog", "Accept or dismiss JS dialogs.", {
720
+ server.tool("browser_dialog", "Accept or dismiss JS dialogs (alert, confirm, prompt). Browser sessions only.", {
578
721
  action: z.enum(["accept", "dismiss"]).describe("Action to perform on the dialog."),
579
722
  promptText: z.string().optional().describe("Text to enter if it is a prompt dialog.")
580
723
  }, async (args) => {
581
724
  try {
725
+ const liveConfig = getCachedConfig();
726
+ const active = sessionRegistry.getActive(liveConfig.agentId);
727
+ if (active?.isElectron) {
728
+ return { content: [{ type: "text", text: "[Note] JS dialog interception is only available for web browser sessions (Electron apps handle dialogs natively)." }] };
729
+ }
582
730
  return await withSecurity(config, "browser_dialog", async (session) => {
583
731
  return await browserDialog(session, args);
584
732
  });
@@ -588,70 +736,78 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
588
736
  }
589
737
  });
590
738
  // ── 8. browser_burst ───────────────────────────────────────────────────────
591
- server.tool("browser_burst", "Chain multiple tool calls in one round-trip. Use the 'click_and_wait' macro step instead of a standard click for buttons like login or buttons that will take time to load a new page.", {
739
+ server.tool("browser_burst", "HIGH-SPEED MULTI-ACTION PIPELINE: Executes 2 or more sequential browser actions in a single round-trip without waiting for LLM turns between steps. PREFER THIS over individual single-action calls (e.g. typing + pressing Enter, filling multiple form fields, clicking tabs/buttons then waiting, searching, navigating + reading) to drastically slash latency and token burn. Supports native primitives (browser_click, browser_fill, browser_type, browser_type_secret, browser_navigate, browser_key, browser_scroll, browser_wait, browser_map, etc.) plus ergonomic macro steps (type_and_send, click_and_wait, navigate_and_read, scroll_and_read, fill_form). If compile_from_history: true is passed, automatically compiles recent successful session trail actions into a burst macro.", {
592
740
  steps: z.array(z.object({
593
- tool: z.enum(["browser_click", "browser_type", "browser_navigate", "browser_key", "browser_scroll", "browser_wait", "browser_map", "browser_pointer", "browser_eval", "browser_select", "type_and_send", "navigate_and_read", "click_and_wait", "scroll_and_read"]),
594
- args: z.record(z.unknown()).describe("Arguments for the respective tool. For type_and_send: { text, x?, y?, id?, label?, pressEnter? }. For navigate_and_read: { url }. For click_and_wait: { id?, label?, x?, y?, wait_for?, wait_value? }. For scroll_and_read: { direction, distance? }.")
595
- })).describe("Ordered array of tool calls to execute serially. Use macro steps to save tokens."),
596
- batch: z.boolean().optional().describe("If true, defers action map generation until all steps are complete to save time.")
741
+ tool: z.string().describe("Tool name or macro step (e.g. browser_click, browser_fill, browser_type, type_and_send, click_and_wait, navigate_and_read, scroll_and_read, fill_form)."),
742
+ args: z.record(z.unknown()).optional().describe("Tool arguments. Alternatively, arguments may be specified flat on the step object."),
743
+ }).passthrough()).optional().describe("Ordered array of tool calls or macros to execute serially. Either 'steps' or 'actions' may be passed."),
744
+ actions: z.array(z.any()).optional().describe("Alias for 'steps'."),
745
+ compile_from_history: z.boolean().optional().describe("Compile recent successful session trail actions into a macro. If steps are omitted, executes the compiled macro directly."),
746
+ history_limit: z.number().optional().describe("Number of recent session trail entries to consider when compile_from_history is true (default: 20)."),
747
+ dry_run: z.boolean().optional().describe("If true with compile_from_history, returns the compiled steps without executing them."),
748
+ stop_on_error: z.boolean().optional().describe("Halt execution immediately if a step fails to prevent cascading failures (default: true)."),
749
+ batch: z.boolean().optional().describe("If true, defers action map generation until all steps are complete to maximize throughput."),
750
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
597
751
  }, async (args) => {
598
752
  try {
599
753
  return await withSecurity(config, "browser_burst", async (session, liveConfig) => {
600
754
  const { browserBurst } = await import("./tools/burst.js");
601
755
  return await browserBurst(session, args, liveConfig);
602
- });
756
+ }, false, args.app);
603
757
  }
604
758
  catch (e) {
605
759
  return wrapError(e);
606
760
  }
607
761
  });
608
762
  // ── 9. browser_tabs ───────────────────────────────────────────────────────
609
- server.tool("browser_tabs", "Manage browser tabs (open, switch, close, list).", {
763
+ server.tool("browser_tabs", "Manage browser and Electron app tabs/windows (open, switch, close, list). Specify 'app' when targeting desktop apps (e.g. app: 'discord') to avoid opening web browser.", {
610
764
  action: z.enum(["list", "open", "close", "switch", "list_windows", "focus_window", "consolidate", "heal"]).describe("Action to perform. 'consolidate' deduplicates tabs. 'heal' recovers crashed tabs."),
611
765
  url: z.string().optional().describe("URL to open (required for 'open' action)."),
766
+ app: z.string().optional().describe("Optional Electron app name configured in config.json under 'electron'."),
612
767
  tabId: z.string().optional().describe("Target Tab ID (required for 'switch' or 'close')."),
613
768
  tabIds: z.array(z.string()).optional().describe("Multiple Tab IDs for batch 'close' action."),
614
769
  force: z.boolean().optional().describe("Force close protected (anchor) tabs. Use with extreme caution."),
615
770
  windowId: z.number().optional().describe("Window ID to focus (required for 'focus_window')."),
616
771
  reuse: z.boolean().optional().describe("Default true. When opening a URL already loaded in another tab, focus that tab instead of opening a duplicate."),
617
- }, async ({ action, url, tabId, tabIds, force, windowId, reuse }) => {
772
+ }, async ({ action, url, app, tabId, tabIds, force, windowId, reuse }) => {
618
773
  try {
774
+ const resolvedApp = inferTargetApp(app, url, tabId || (tabIds && tabIds.length > 0 ? tabIds[0] : undefined));
619
775
  return await withSecurity(config, "browser_tabs", async (_, liveConfig) => {
620
776
  if (action === "list")
621
- return await WeavetabList(liveConfig);
777
+ return await WeavetabList(liveConfig, resolvedApp);
622
778
  if (action === "open") {
623
779
  if (!url)
624
780
  throw new Error("url is required for action: open");
625
- return await WeavetabOpen(url, liveConfig, reuse !== false);
781
+ return await WeavetabOpen(url, liveConfig, reuse !== false, resolvedApp);
626
782
  }
627
783
  if (action === "switch") {
628
784
  if (!tabId)
629
785
  throw new Error("tabId is required for action: switch");
630
- return await WeavetabSwitch(tabId, liveConfig);
786
+ return await WeavetabSwitch(tabId, liveConfig, resolvedApp);
631
787
  }
632
788
  if (action === "close") {
633
789
  if (tabIds && tabIds.length > 0)
634
- return await WeavetabCloseBatch(tabIds, liveConfig, force);
790
+ return await WeavetabCloseBatch(tabIds, liveConfig, force, resolvedApp);
635
791
  if (!tabId)
636
792
  throw new Error("tabId or tabIds is required for action: close");
637
- return await WeavetabClose(tabId, liveConfig, force);
793
+ return await WeavetabClose(tabId, liveConfig, force, resolvedApp);
638
794
  }
639
795
  if (action === "list_windows") {
640
- return await weaveWindowList(liveConfig);
796
+ return await weaveWindowList(liveConfig, resolvedApp);
641
797
  }
642
798
  if (action === "focus_window") {
643
799
  if (windowId === undefined)
644
800
  throw new Error("windowId is required for action: focus_window");
645
- return await weaveFocusWindow(windowId, liveConfig);
801
+ return await weaveFocusWindow(windowId, liveConfig, resolvedApp);
646
802
  }
647
803
  if (action === "consolidate") {
648
- return await WeavetabConsolidate(liveConfig);
804
+ return await WeavetabConsolidate(liveConfig, resolvedApp);
649
805
  }
650
806
  if (action === "heal") {
651
- return await WeavetabHeal(liveConfig);
807
+ return await WeavetabHeal(liveConfig, resolvedApp);
652
808
  }
653
809
  throw new Error("Invalid action");
654
- });
810
+ }, false, resolvedApp);
655
811
  }
656
812
  catch (e) {
657
813
  return wrapError(e);
@@ -663,17 +819,18 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
663
819
  value: z.string().optional().describe("CSS selector, text string, or URL pattern. Not needed for 'stable'/'network'."),
664
820
  timeout: z.number().optional().describe("Max wait in ms (default 8000)."),
665
821
  tab_id: z.string().optional().describe("Execute in a specific tab instead of active."),
822
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
666
823
  }, async (args) => {
667
824
  try {
668
825
  return await withSecurity(config, "browser_wait", async (session, liveConfig) => {
669
826
  let targetSession = session;
670
827
  if (args.tab_id) {
671
828
  const { connectToTab } = await import("./cdp/connector.js");
672
- const client = await connectToTab(args.tab_id);
829
+ const client = await connectToTab(args.tab_id, undefined, undefined, undefined, args.app);
673
830
  targetSession = client.session;
674
831
  }
675
832
  return await browserWait(targetSession, args, liveConfig);
676
- });
833
+ }, false, args.app);
677
834
  }
678
835
  catch (e) {
679
836
  return wrapError(e);
@@ -694,13 +851,27 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
694
851
  }
695
852
  });
696
853
  // ── 12. browser_thoughts ─────────────────────────────────────────────────────────
697
- server.tool("browser_thoughts", "Agent-to-user communication. Use 'question' mode to ask a free-text question. Use 'feedback' mode to provide status updates or analysis (green sticky feedback).", {
854
+ server.tool("browser_thoughts", "Agent-to-user communication. Renders in the middle down of the page. Use 'question' mode to ask a free-text question. Use 'feedback' mode to provide status updates, analysis, or decisions. Supports element anchoring (e.g. 'w:15') and presentation styles ('observation', 'decision', 'warning', 'question').", {
698
855
  action: z.enum(["question", "feedback"]).describe("Action to perform."),
699
856
  message: z.string().describe("The question to ask, or the feedback/analysis to log."),
857
+ anchor: z.string().optional().describe("Optional element Ref ID (e.g. 'w:15') or CSS selector to visually anchor this thought to on the page."),
858
+ style: z.enum(["observation", "decision", "warning", "question"]).optional().describe("Thought presentation style. Defaults to 'observation' for feedback and 'question' for question."),
700
859
  input: z.enum(["sentence", "paragraph"]).optional().describe("Input field type for question mode (default: sentence)."),
701
860
  timeout: z.number().optional().describe("Timeout in seconds for question mode before auto-cancel (default: 120)."),
861
+ app: z.string().optional().describe("Optional Electron app name. (Note: thought HUD overlay is browser-only)."),
702
862
  }, async (args) => {
703
863
  try {
864
+ const liveConfig = getCachedConfig();
865
+ const active = sessionRegistry.getActive(liveConfig.agentId, args.app);
866
+ if (args.app || active?.isElectron) {
867
+ const { logActionStructured } = await import("./audit/logger.js");
868
+ logActionStructured({
869
+ timestamp: new Date().toISOString(),
870
+ tool: "browser_thoughts",
871
+ result: `[ELECTRON APP FEEDBACK]: ${args.message}`
872
+ });
873
+ return { content: [{ type: "text", text: "[Note] Thought/HUD overlay is disabled for Electron desktop apps (DOM-only execution). Feedback recorded in audit log." }] };
874
+ }
704
875
  return await withSecurity(config, "browser_thoughts", async (session) => {
705
876
  if (args.action === "feedback") {
706
877
  const { logActionStructured } = await import("./audit/logger.js");
@@ -709,10 +880,22 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
709
880
  tool: "browser_thoughts",
710
881
  result: `[AGENT FEEDBACK]: ${args.message}`
711
882
  });
712
- await browserThought(session, { action: "feedback", message: args.message });
883
+ await browserThought(session, {
884
+ action: "feedback",
885
+ message: args.message,
886
+ anchor: args.anchor,
887
+ style: args.style,
888
+ });
713
889
  return { content: [{ type: "text", text: "Feedback appended to mission log and HUD." }] };
714
890
  }
715
- const result = await browserThought(session, { action: "question", message: args.message, input: args.input, timeout: args.timeout });
891
+ const result = await browserThought(session, {
892
+ action: "question",
893
+ message: args.message,
894
+ anchor: args.anchor,
895
+ style: args.style,
896
+ input: args.input,
897
+ timeout: args.timeout,
898
+ });
716
899
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
717
900
  });
718
901
  }
@@ -721,12 +904,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
721
904
  }
722
905
  });
723
906
  // ── 13. browser_pointer ───────────────────────────────────────────────────────
724
- server.tool("browser_pointer", "Advanced pointer control (hover, drag).", {
725
- action: z.enum(["hover", "click", "double_click", "drag_and_drop"]).describe("Action to perform."),
907
+ server.tool("browser_pointer", "Advanced pointer control (hover, click, double_click, drag_and_drop, hover_chain). Use hover_chain for Photoshop/Photopea, Figma, and multi-level flyout submenus.", {
908
+ action: z.enum(["hover", "click", "double_click", "drag_and_drop", "hover_chain"]).optional().describe("Action to perform (default: click, or hover_chain if hover_chain array is provided)."),
726
909
  ref: z.string().optional().describe("Ref ID of the target element (e.g. w:42)."),
727
910
  id: z.string().optional().describe("Alternative element ID / Ref ID from browser_map."),
728
911
  selector: z.string().optional().describe("CSS Selector of the target element."),
729
912
  target: z.string().optional().describe("Alternative target element identifier or query."),
913
+ label: z.string().optional().describe("Target element label or text."),
730
914
  x: z.number().optional().describe("Raw X coordinate (fallback)."),
731
915
  y: z.number().optional().describe("Raw Y coordinate (fallback)."),
732
916
  end_ref: z.string().optional().describe("Destination Ref ID (only for drag_and_drop)."),
@@ -736,7 +920,11 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
736
920
  end_x: z.number().optional().describe("Destination X coordinate (only for drag_and_drop)."),
737
921
  end_y: z.number().optional().describe("Destination Y coordinate (only for drag_and_drop)."),
738
922
  waitForChild: z.string().optional().describe("CSS Selector of a child element to wait for (only for hover)."),
739
- holdMs: z.number().optional().describe("Milliseconds to hold the pointer down (only for hover).")
923
+ holdMs: z.number().optional().describe("Milliseconds to hold the pointer down (only for hover)."),
924
+ hover_chain: z.array(z.string()).optional().describe("Array of element ref IDs, selectors, or labels to hover sequentially and click the last one (e.g. ['File', 'Export As', 'PNG']). Perfect for Photoshop/Photopea, Figma, Canva, and multi-level flyout submenus."),
925
+ chain: z.array(z.any()).optional().describe("Advanced chain step array of { action, ref, label, selector, x, y, holdMs }."),
926
+ clickFinal: z.boolean().optional().describe("Default true. Whether to click the final element in the hover chain."),
927
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
740
928
  }, async (args) => {
741
929
  try {
742
930
  return await withSecurity(config, "browser_pointer", async (session, liveConfig) => {
@@ -754,7 +942,7 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
754
942
  if (args.end_target && !normalizedArgs.end_ref)
755
943
  normalizedArgs.end_ref = args.end_target;
756
944
  return await browserPointer(session, normalizedArgs, liveConfig);
757
- });
945
+ }, false, args.app);
758
946
  }
759
947
  catch (e) {
760
948
  return wrapError(e);
@@ -765,41 +953,56 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
765
953
  nodeId: z.number().optional().describe("CDP backendNodeId."),
766
954
  selector: z.string().optional().describe("CSS Selector."),
767
955
  value: z.string().describe("The exact text value to select."),
956
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
768
957
  }, async (args) => {
769
958
  try {
770
959
  return await withSecurity(config, "browser_select", async (session, liveConfig) => {
771
960
  return await browserSelect(session, args, liveConfig);
772
- });
961
+ }, false, args.app);
773
962
  }
774
963
  catch (e) {
775
964
  return wrapError(e);
776
965
  }
777
966
  });
778
967
  // ── 14b. browser_fill ──────────────────────────────────────────────────────
779
- server.tool("browser_fill", "Smart multi-field form-filling. Best for big complex forms to save round-trips.", {
968
+ server.tool("browser_fill", "Smart multi-field form-filling and input inspector. Call with scan:true (or empty) to get a lightweight list of all editable inputs on the page without dumping the full DOM. Fill multiple fields in a single turn.", {
780
969
  fields: z.array(z.object({
781
- ref: z.string().describe("Element ref ID (w:NN) from browser_map."),
970
+ ref: z.string().optional().describe("Element ref ID (w:NN) from browser_map or input scan."),
971
+ selector: z.string().optional().describe("CSS selector of the field."),
972
+ target: z.string().optional().describe("Alternative selector or query."),
973
+ name: z.string().optional().describe("HTML name attribute of the field."),
974
+ label: z.string().optional().describe("Label or placeholder text."),
782
975
  value: z.string().describe("Value to fill/select/type into the field."),
783
- })).describe("Array of field ref+value pairs to fill sequentially."),
976
+ })).optional().describe("Array of field ref+value pairs to fill sequentially."),
977
+ scan: z.boolean().optional().describe("Set true to discover and return all fillable inputs on the page (with ref IDs, types, and values) in ~30 tokens without full browser_map."),
978
+ inputs: z.boolean().optional().describe("Alias for scan."),
979
+ scan_only: z.boolean().optional().describe("Validate field types without mutating DOM."),
980
+ blast_mode: z.boolean().optional().describe("Fill all fields simultaneously in a single CDP eval round-trip."),
784
981
  submit: z.string().optional().describe("Optional ref ID (w:NN) of a submit button to click after filling all fields."),
982
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
785
983
  }, async (args) => {
786
984
  try {
787
985
  return await withSecurity(config, "browser_fill", async (session, liveConfig) => {
788
986
  return await agentAction(session, 'typing', () => browserFill(session, args, liveConfig), 'browser_fill');
789
- });
987
+ }, false, args.app);
790
988
  }
791
989
  catch (e) {
792
990
  return wrapError(e);
793
991
  }
794
992
  });
795
993
  // ── 18. browser_highlight ───────────────────────────────────────────────────
796
- server.tool("browser_highlight", "Highlight an element with a colored overlay.", {
994
+ server.tool("browser_highlight", "Highlight elements on page. Supports single or multiple candidates (refs) with styles ('consideration' for candidate evaluation, 'target' for lock-on, 'danger' for destructive actions, 'success', or 'shimmer'), floating badges, and configurable duration.", {
797
995
  selector: z.string().optional().describe("CSS Selector of the element to highlight."),
798
996
  id: z.string().optional().describe("Alternative element Ref ID from browser_map (e.g. 'w:42')."),
799
997
  ref: z.string().optional().describe("Alternative element Ref ID (e.g. 'w:42')."),
998
+ refs: z.array(z.string()).optional().describe("Array of element Ref IDs to highlight concurrently (e.g. ['w:12', 'w:15', 'w:23'])."),
800
999
  target: z.string().optional().describe("Alternative element identifier, CSS Selector, or query."),
1000
+ style: z.enum(["consideration", "target", "danger", "success", "shimmer"]).optional().describe("Visual highlight style. Defaults to 'shimmer' (or 'consideration' when refs provided)."),
1001
+ label: z.string().optional().describe("Floating label badge text to display above the element(s)."),
1002
+ duration: z.number().optional().describe("Duration in ms to keep highlight visible (default: 2000ms for consideration/target, 600ms for shimmer)."),
801
1003
  nodeId: z.number().optional().describe("CDP backendNodeId to highlight directly."),
802
- color: z.string().optional().describe("CSS color to use for the overlay (e.g. 'rgba(255,0,0,0.5)').")
1004
+ color: z.string().optional().describe("Custom CSS color to use for the overlay (e.g. 'rgba(255,0,0,0.5)')."),
1005
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
803
1006
  }, async (args) => {
804
1007
  try {
805
1008
  return await withSecurity(config, "browser_highlight", async (session, liveConfig) => {
@@ -817,7 +1020,7 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
817
1020
  delete normalizedArgs.ref;
818
1021
  delete normalizedArgs.target;
819
1022
  return await browserHighlight(session, normalizedArgs, liveConfig);
820
- });
1023
+ }, false, args.app);
821
1024
  }
822
1025
  catch (e) {
823
1026
  return wrapError(e);
@@ -826,25 +1029,29 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
826
1029
  // ── 20. browser_upload ──────────────────────────────────────────────────────
827
1030
  server.tool("browser_upload", "Upload files to a file input.", {
828
1031
  nodeId: z.number().describe("CDP backendNodeId of the target <input type='file'> element."),
829
- filePaths: z.array(z.string()).describe("List of absolute system file paths to upload.")
1032
+ filePaths: z.array(z.string()).describe("List of absolute system file paths to upload."),
1033
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
830
1034
  }, async (args) => {
831
1035
  try {
832
1036
  return await withSecurity(config, "browser_upload", async (session, liveConfig) => {
833
1037
  return await browserUpload(session, args, liveConfig);
834
- });
1038
+ }, false, args.app);
835
1039
  }
836
1040
  catch (e) {
837
1041
  return wrapError(e);
838
1042
  }
839
1043
  });
840
1044
  // ── 22. browser_find ────────────────────────────────────────────────────────
841
- server.tool("browser_find", "Sub-5ms CDP text search. Zero token waste. Best for locating specific keywords/text on page without a full DOM walk. No refs returned (use browser_map for refs). No shadow roots/regex.", {
842
- query: z.string().describe("Exact text query to find on the page.")
1045
+ server.tool("browser_find", "Instant text & keyword search across page text, labels, and placeholders. Returns matched element coordinates, selectors, and ref IDs.", {
1046
+ query: z.string().describe("Text or keyword query to search for on the page."),
1047
+ all: z.boolean().optional().describe("Set true to return all matching elements on the page (default false returns first match)."),
1048
+ limit: z.number().optional().describe("Max number of matches to return when all: true (default 10)."),
1049
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
843
1050
  }, async (args) => {
844
1051
  try {
845
1052
  return await withSecurity(config, "browser_find", async (session, liveConfig) => {
846
1053
  return await agentAction(session, 'reading', () => browserFind(session, args, liveConfig), 'browser_find');
847
- });
1054
+ }, false, args.app);
848
1055
  }
849
1056
  catch (e) {
850
1057
  return wrapError(e);
@@ -856,11 +1063,12 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
856
1063
  action: z.enum(["get", "set", "clear"]).describe("Action to perform on the storage."),
857
1064
  key: z.string().optional().describe("Key name (required for 'get' and 'set' actions)."),
858
1065
  value: z.string().optional().describe("String value (required for 'set' action)."),
1066
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
859
1067
  }, async (args) => {
860
1068
  try {
861
1069
  return await withSecurity(config, "browser_storage", async (session, liveConfig) => {
862
1070
  return await browserStorage(session, args, liveConfig);
863
- });
1071
+ }, false, args.app);
864
1072
  }
865
1073
  catch (e) {
866
1074
  return wrapError(e);
@@ -872,11 +1080,12 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
872
1080
  pattern: z.string().optional().describe("URL glob pattern to intercept or filter telemetry (e.g. '*api/login*')."),
873
1081
  mockStatus: z.number().optional().describe("HTTP status code to return when enabling mock (default 200)."),
874
1082
  mockBody: z.string().optional().describe("JSON or plaintext body payload to return in the mocked response."),
1083
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
875
1084
  }, async (args) => {
876
1085
  try {
877
1086
  return await withSecurity(config, "browser_network_intercept", async (session, liveConfig) => {
878
1087
  return await browserNetworkIntercept(session, args, liveConfig);
879
- });
1088
+ }, false, args.app);
880
1089
  }
881
1090
  catch (e) {
882
1091
  return wrapError(e);
@@ -884,17 +1093,18 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
884
1093
  });
885
1094
  // ── 25. browser_console ─────────────────────────────────────────────────────
886
1095
  server.tool("browser_console", "1-5ms latency. Read or clear buffered console API messages, JS exceptions, and network errors.", {
887
- action: z.enum(["read", "clear"]).describe("Action to perform on the console buffer.")
1096
+ action: z.enum(["read", "clear"]).describe("Action to perform on the console buffer."),
1097
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
888
1098
  }, async (args) => {
889
1099
  try {
890
1100
  return await withSecurity(config, "browser_console", async (session, liveConfig) => {
891
1101
  let targetId = "default";
892
1102
  const { sessionRegistry } = await import("./state/session.js");
893
- const active = sessionRegistry.getActive(liveConfig.agentId);
1103
+ const active = sessionRegistry.getActive(liveConfig.agentId, args.app);
894
1104
  if (active)
895
1105
  targetId = active.targetId;
896
1106
  return await browserConsole(session, args, liveConfig, targetId);
897
- });
1107
+ }, false, args.app);
898
1108
  }
899
1109
  catch (e) {
900
1110
  return wrapError(e);
@@ -906,11 +1116,12 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
906
1116
  landscape: z.boolean().optional().describe("Print in landscape orientation (default false)."),
907
1117
  scale: z.number().optional().describe("Scale factor (default 1.0)."),
908
1118
  pageRanges: z.string().optional().describe("Page ranges to print (e.g. '1-5'). Leave empty for all pages."),
1119
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
909
1120
  }, async (args) => {
910
1121
  try {
911
1122
  return await withSecurity(config, "browser_pdf", async (session, liveConfig) => {
912
1123
  return await browserPdf(session, args, liveConfig);
913
- });
1124
+ }, false, args.app);
914
1125
  }
915
1126
  catch (e) {
916
1127
  return wrapError(e);
@@ -919,12 +1130,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
919
1130
  // ── 27. browser_eval ────────────────────────────────────────────────────────
920
1131
  server.tool("browser_eval", "Run arbitrary JS via CDP. Good for extracting app state. Cannot traverse shadow DOM/cross-origin iframes.", {
921
1132
  expression: z.string().describe("JavaScript code to evaluate in the context of the page."),
922
- timeout: z.number().optional().describe("Timeout in ms (default 5000).")
1133
+ timeout: z.number().optional().describe("Timeout in ms (default 5000)."),
1134
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
923
1135
  }, async (args) => {
924
1136
  try {
925
1137
  return await withSecurity(config, "browser_eval", async (session, liveConfig) => {
926
1138
  return await agentAction(session, 'reading', () => browserEval(session, args, liveConfig), 'browser_eval');
927
- });
1139
+ }, false, args.app);
928
1140
  }
929
1141
  catch (e) {
930
1142
  return wrapError(e);
@@ -938,11 +1150,12 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
938
1150
  domain: z.string().optional().describe("Domain for the cookie."),
939
1151
  path: z.string().optional().describe("Path for the cookie."),
940
1152
  url: z.string().optional().describe("URL scope for 'get' action (defaults to current page)."),
1153
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
941
1154
  }, async (args) => {
942
1155
  try {
943
1156
  return await withSecurity(config, "browser_cookies", async (session, liveConfig) => {
944
1157
  return await browserCookies(session, args, liveConfig);
945
- });
1158
+ }, false, args.app);
946
1159
  }
947
1160
  catch (e) {
948
1161
  return wrapError(e);
@@ -956,12 +1169,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
956
1169
  label: z.string().optional().describe("Visible text or aria-label of the canvas element."),
957
1170
  ref: z.string().optional().describe("Alternative ref identifier for the canvas element."),
958
1171
  backendNodeId: z.number().optional().describe("Raw CDP backendNodeId for direct targeting."),
959
- points: z.array(z.object({ x: z.number(), y: z.number() })).optional().describe("Array of {x,y} points relative to canvas viewport for 'draw' action. Simulates a drag path across the canvas.")
1172
+ points: z.array(z.object({ x: z.number(), y: z.number() })).optional().describe("Array of {x,y} points relative to canvas viewport for 'draw' action. Simulates a drag path across the canvas."),
1173
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
960
1174
  }, async (args) => {
961
1175
  try {
962
1176
  return await withSecurity(config, "browser_canvas", async (session, liveConfig) => {
963
1177
  return await browserCanvas(session, args, liveConfig);
964
- });
1178
+ }, false, args.app);
965
1179
  }
966
1180
  catch (e) {
967
1181
  return wrapError(e);
@@ -972,12 +1186,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
972
1186
  query: z.string().describe("CSS Selector or XPath query to extract."),
973
1187
  type: z.enum(["css", "xpath"]).optional().describe("Query type (default 'css')."),
974
1188
  attributes: z.array(z.string()).optional().describe("Attributes to extract (e.g. ['textContent', 'href'], default ['textContent'])."),
975
- multiple: z.boolean().optional().describe("Whether to extract multiple elements (default true).")
1189
+ multiple: z.boolean().optional().describe("Whether to extract multiple elements (default true)."),
1190
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
976
1191
  }, async (args) => {
977
1192
  try {
978
1193
  return await withSecurity(config, "browser_scrape", async (session, liveConfig) => {
979
1194
  return await agentAction(session, 'reading', () => browserScrape(session, args, liveConfig), 'browser_scrape');
980
- });
1195
+ }, false, args.app);
981
1196
  }
982
1197
  catch (e) {
983
1198
  return wrapError(e);
@@ -986,12 +1201,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
986
1201
  // ── 32. browser_snapshot ────────────────────────────────────────────────────
987
1202
  server.tool("browser_snapshot", "Capture or restore an MHTML snapshot. Best for offline archival, replay testing, and compliance audits.", {
988
1203
  action: z.enum(["capture", "restore"]).describe("Action to perform."),
989
- filePath: z.string().describe("Absolute file path to save/load the .mhtml snapshot.")
1204
+ filePath: z.string().describe("Absolute file path to save/load the .mhtml snapshot."),
1205
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
990
1206
  }, async (args) => {
991
1207
  try {
992
1208
  return await withSecurity(config, "browser_snapshot", async (session, liveConfig) => {
993
1209
  return await browserSnapshot(session, args, liveConfig);
994
- });
1210
+ }, false, args.app);
995
1211
  }
996
1212
  catch (e) {
997
1213
  return wrapError(e);
@@ -1007,12 +1223,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1007
1223
  y: z.number().describe("Y coordinate of clip region."),
1008
1224
  width: z.number().describe("Width of clip region."),
1009
1225
  height: z.number().describe("Height of clip region.")
1010
- }).optional().describe("Clip region. Use to capture only part of the page (e.g. just a chart area) to reduce token cost.")
1226
+ }).optional().describe("Clip region. Use to capture only part of the page (e.g. just a chart area) to reduce token cost."),
1227
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1011
1228
  }, async (args) => {
1012
1229
  try {
1013
1230
  return await withSecurity(config, "browser_screenshot", async (session, liveConfig) => {
1014
1231
  return await agentAction(session, 'reading', () => browserScreenshot(session, args, liveConfig), 'browser_screenshot');
1015
- });
1232
+ }, false, args.app);
1016
1233
  }
1017
1234
  catch (e) {
1018
1235
  return wrapError(e);
@@ -1024,12 +1241,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1024
1241
  width: z.number().optional().describe("Viewport width in pixels."),
1025
1242
  height: z.number().optional().describe("Viewport height in pixels."),
1026
1243
  deviceScaleFactor: z.number().optional().describe("Device Pixel Ratio (default 1)."),
1027
- mobile: z.boolean().optional().describe("Whether to emulate a mobile device (default false).")
1244
+ mobile: z.boolean().optional().describe("Whether to emulate a mobile device (default false)."),
1245
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1028
1246
  }, async (args) => {
1029
1247
  try {
1030
1248
  return await withSecurity(config, "browser_viewport", async (session, liveConfig) => {
1031
1249
  return await browserViewport(session, args, liveConfig);
1032
- });
1250
+ }, false, args.app);
1033
1251
  }
1034
1252
  catch (e) {
1035
1253
  return wrapError(e);
@@ -1040,27 +1258,29 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1040
1258
  action: z.enum(["read", "write"]).describe("Action to perform."),
1041
1259
  text: z.string().optional().describe("Text to write to clipboard (for write action)."),
1042
1260
  dataUrl: z.string().optional().describe("Base64 data URL of an image to write to clipboard (e.g. 'data:image/png;base64,...')."),
1043
- mimeType: z.string().optional().describe("MIME type override for image write (auto-detected from dataUrl by default, e.g. 'image/png').")
1261
+ mimeType: z.string().optional().describe("MIME type override for image write (auto-detected from dataUrl by default, e.g. 'image/png')."),
1262
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1044
1263
  }, async (args) => {
1045
1264
  try {
1046
1265
  return await withSecurity(config, "browser_clipboard", async (session, liveConfig) => {
1047
1266
  return await browserClipboard(session, args, liveConfig);
1048
- });
1267
+ }, false, args.app);
1049
1268
  }
1050
1269
  catch (e) {
1051
1270
  return wrapError(e);
1052
1271
  }
1053
1272
  });
1054
1273
  // ── 36. browser_inspect ─────────────────────────────────────────────────────
1055
- server.tool("browser_inspect", "15-35ms latency, zero waste. Deep DOM inspection. Crucial for debugging why a click failed, verifying z-index & visibility, or computed styles.", {
1274
+ server.tool("browser_inspect", "15-35ms latency, zero waste. Deep DOM inspection. Useful for debugging failed clicks, verifying z-index & visibility, or computed styles.", {
1056
1275
  nodeId: z.number().optional().describe("CDP backendNodeId."),
1057
1276
  selector: z.string().optional().describe("CSS Selector."),
1058
- ref: z.string().optional().describe("Ref ID (e.g. 'w:42').")
1277
+ ref: z.string().optional().describe("Ref ID (e.g. 'w:42')."),
1278
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1059
1279
  }, async (args) => {
1060
1280
  try {
1061
1281
  return await withSecurity(config, "browser_inspect", async (session, liveConfig) => {
1062
1282
  return await agentAction(session, 'reading', () => browserInspect(session, args, liveConfig), 'browser_inspect');
1063
- });
1283
+ }, false, args.app);
1064
1284
  }
1065
1285
  catch (e) {
1066
1286
  return wrapError(e);
@@ -1073,12 +1293,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1073
1293
  outputDir: z.string().optional().describe("Optional custom directory to write extracted files into. If omitted, uses configured designDir (~/.weavetab/designs/<domain>-<timestamp>/)."),
1074
1294
  name: z.string().optional().describe("Optional custom name for the saved design folder (e.g. 'resend-hero', 'pricing-section')."),
1075
1295
  includeAssets: z.boolean().optional().describe("Whether to capture inline SVGs and asset references (default true)."),
1076
- hideOverlay: z.boolean().optional().describe("Whether to hide Weavetab overlay during capture (default true).")
1296
+ hideOverlay: z.boolean().optional().describe("Whether to hide Weavetab overlay during capture (default true)."),
1297
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1077
1298
  }, async (args) => {
1078
1299
  try {
1079
1300
  return await withSecurity(config, "browser_extract_design", async (session, liveConfig) => {
1080
1301
  return await agentAction(session, 'reading', () => browserExtractDesign(session, args, liveConfig), 'browser_extract_design');
1081
- });
1302
+ }, false, args.app);
1082
1303
  }
1083
1304
  catch (e) {
1084
1305
  return wrapError(e);
@@ -1091,12 +1312,13 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1091
1312
  script: z.string().optional().describe("JavaScript code to inject and run (required for start)."),
1092
1313
  persistent: z.boolean().optional().describe("If true, the script re-runs in a loop (default false)."),
1093
1314
  onResult: z.enum(["notify_agent", "store", "ignore"]).optional().describe("How to handle script results (default 'store')."),
1315
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1094
1316
  }, async (args) => {
1095
1317
  try {
1096
1318
  return await withSecurity(config, "browser_automation", async (session, liveConfig) => {
1097
1319
  const result = await browserAutomation(session, args, liveConfig);
1098
1320
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
1099
- });
1321
+ }, false, args.app);
1100
1322
  }
1101
1323
  catch (e) {
1102
1324
  return wrapError(e);
@@ -1110,28 +1332,22 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1110
1332
  maxFrames: z.number().optional().describe("Maximum frames to capture (default 500)."),
1111
1333
  format: z.enum(["webp", "gif", "webm", "mp4", "frames"]).optional().describe("Output video format when stopping recording. Default 'webp' (pure Node native compilation)."),
1112
1334
  keepFrames: z.boolean().optional().describe("If true, keeps raw JPEG frames after video compilation (default false)."),
1335
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1113
1336
  }, async (args) => {
1114
1337
  try {
1115
1338
  return await withSecurity(config, "browser_recording", async (session, liveConfig) => {
1116
1339
  const result = await browserRecording(session, args, liveConfig);
1117
1340
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
1118
- });
1341
+ }, false, args.app);
1119
1342
  }
1120
1343
  catch (e) {
1121
1344
  return wrapError(e);
1122
1345
  }
1123
1346
  });
1124
- // ── 38. browser_reset_loop_counter ─────────────────────────────────────────
1125
- server.tool("browser_reset_loop_counter", "Reset the loop detection counter.", {
1126
- reason: z.string().optional().describe("Optional reason for the reset (e.g. 'new_task', 'manual_recovery')."),
1127
- }, async (args) => {
1128
- resetLoopCounter(args.reason || "manual_tool_call");
1129
- return { content: [{ type: "text", text: JSON.stringify({ success: true, reason: args.reason || "manual_tool_call" }) }] };
1130
- });
1131
1347
  // ── 37. browser_detect ─────────────────────────────────────────────────────
1132
- server.tool("browser_detect", "Detect available browsers on the machine.", {}, async () => {
1348
+ server.tool("browser_detect", "CRITICAL INITIALIZATION TOOL. Detect available browsers and configured desktop apps (Discord, Slack, etc.). Call this FIRST before interacting with the environment to know if apps are online/offline and what tabs they have open.", {}, async () => {
1133
1349
  try {
1134
- const result = detectBrowsers(getActiveEngine());
1350
+ const result = await detectBrowsers(getActiveEngine());
1135
1351
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
1136
1352
  }
1137
1353
  catch (e) {
@@ -1139,84 +1355,126 @@ export async function startServer(config, transportMode = "stdio", wsPort = 3000
1139
1355
  }
1140
1356
  });
1141
1357
  // ── browser_performance ───────────────────────────────────────────────────
1142
- server.tool("browser_performance", "Retrieve page performance metrics.", {}, async () => {
1358
+ server.tool("browser_performance", "Retrieve page performance metrics.", {
1359
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1360
+ }, async (args) => {
1143
1361
  try {
1144
1362
  return await withSecurity(config, "browser_performance", async (session, liveConfig) => {
1145
1363
  const result = await browserPerformance(session, liveConfig);
1146
1364
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
1147
- });
1365
+ }, false, args.app);
1148
1366
  }
1149
1367
  catch (e) {
1150
1368
  return wrapError(e);
1151
1369
  }
1152
1370
  });
1153
- // ── browser_macro_compile ─────────────────────────────────────────────────
1154
- server.tool("browser_macro_compile", "Compile a recent trail into a burst macro.", {
1155
- trail_steps: z.array(z.any()).describe("An array of TrailStep objects representing successful steps.")
1156
- }, async (args) => {
1157
- try {
1158
- const result = compileTraceToMacro(args.trail_steps);
1159
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
1160
- }
1161
- catch (e) {
1162
- return wrapError(e);
1163
- }
1164
- });
1165
- // ── GitHub Tools (v2.5.0 Core Inclusion) ──────────────────────────────────
1166
- server.tool("github_analyze", "Analyze a GitHub repository.", {
1167
- repo: z.string().optional().describe("Optional 'owner/repo' slug. If omitted, uses the active tab's repository.")
1168
- }, async (args) => {
1169
- try {
1170
- return await withSecurity(config, "github_analyze", async (session, liveConfig) => {
1171
- // Defence-in-depth: coerce to string | undefined at the server boundary.
1172
- // Zod marks `repo` as optional(string) but (args as any) bypasses that
1173
- // guarantee, so we normalise here before forwarding to githubAnalyze.
1174
- const rawRepo = args.repo;
1175
- const repo = typeof rawRepo === "string" && rawRepo.trim() !== ""
1176
- ? rawRepo.trim()
1177
- : undefined;
1178
- return await githubAnalyze(session, liveConfig, repo);
1179
- });
1180
- }
1181
- catch (e) {
1182
- return wrapError(e);
1183
- }
1184
- });
1185
- server.tool("github_read", "Read file contents from a GitHub repository.", {
1186
- paths: z.array(z.string()).describe("List of file paths to read, relative to the repository root."),
1187
- line_range: z.array(z.number()).length(2).optional().describe("Optional [start, end] line range to read specific lines instead of the default first 200 lines. 1-indexed.")
1188
- }, async (args) => {
1189
- try {
1190
- return await withSecurity(config, "github_read", async (session, liveConfig) => {
1191
- return await githubRead(session, args, liveConfig);
1192
- });
1193
- }
1194
- catch (e) {
1195
- return wrapError(e);
1196
- }
1197
- });
1198
- server.tool("github_issues", "Interact with GitHub issues.", {
1199
- search: z.string().optional().describe("Search query to find issues."),
1200
- number: z.number().optional().describe("Specific issue number to fetch details and comments for."),
1201
- labels: z.array(z.string()).optional().describe("Filter issues by labels."),
1202
- state: z.enum(["open", "closed", "all"]).optional().describe("Filter issues by state."),
1203
- limit: z.number().optional().describe("Max number of issues to list. Default 5.")
1371
+ // ── browser_pattern_learn ─────────────────────────────────────────────────
1372
+ server.tool("browser_pattern_learn", "Read, register, update, and record execution outcomes for named workflow patterns and site quirks in ~/.weavetab-system/memory/sites/. Use action='get' at session start to inspect a site profile and decide between fast-path replay (reliability ≥ 0.8) or exploratory navigation. Use action='learn' after completing a multi-step flow to persist it. Use action='record_execution' to update reliability scores after replay attempts.", {
1373
+ origin: z.string().optional().describe("Full origin associated with the pattern, e.g. 'https://github.com'. Required for write actions. If omitted with action='get', reads the active tab origin."),
1374
+ action: z.enum(["get", "learn", "record_execution", "record_quirk"]).default("learn").describe("'get': inspect site profile and known workflow patterns. 'learn': create/update a named pattern. 'record_execution': update reliability after replay. 'record_quirk': store a site quirk."),
1375
+ pattern: z.string().optional().describe("With action='get': returns reliability score for this specific named pattern only."),
1376
+ name: z.string().optional().describe("Pattern name in snake_case (required for 'learn' and 'record_execution')."),
1377
+ steps: z.array(z.object({
1378
+ tool: z.string().describe("MCP tool name, e.g. 'browser_click'."),
1379
+ target: z.string().optional().describe("Selector or URL."),
1380
+ value: z.string().optional().describe("Value for browser_type/browser_fill/browser_select."),
1381
+ extra: z.record(z.any()).optional().describe("Additional tool arguments.")
1382
+ })).optional().describe("Ordered workflow steps (required for 'learn')."),
1383
+ success_indicator: z.string().optional().describe("CSS selector or URL fragment confirming workflow success."),
1384
+ entry: z.string().optional().describe("URL path or pattern where this workflow starts."),
1385
+ success: z.boolean().optional().describe("Whether the execution succeeded (required for 'record_execution')."),
1386
+ duration_ms: z.number().optional().describe("Execution duration in ms (optional for 'record_execution')."),
1387
+ quirk_key: z.string().optional().describe("Quirk key: 'cookie_banner', 'rate_limit', or 'modal_popups' (required for 'record_quirk')."),
1388
+ quirk_data: z.record(z.any()).optional().describe("Quirk payload object (required for 'record_quirk').")
1204
1389
  }, async (args) => {
1205
1390
  try {
1206
- return await withSecurity(config, "github_issues", async (session) => {
1207
- return await githubIssues(session, args);
1208
- });
1391
+ if (args.action === "get") {
1392
+ let resolvedOrigin = args.origin;
1393
+ if (!resolvedOrigin) {
1394
+ return await withSecurity(config, "browser_pattern_learn", async (session, _liveConfig) => {
1395
+ try {
1396
+ const res = await session.Runtime.evaluate({
1397
+ expression: "window.location.origin",
1398
+ returnByValue: true,
1399
+ });
1400
+ resolvedOrigin = res?.result?.value;
1401
+ }
1402
+ catch { /* fall through */ }
1403
+ if (!resolvedOrigin) {
1404
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Could not determine origin from active tab. Pass origin explicitly." }) }] };
1405
+ }
1406
+ if (args.pattern) {
1407
+ const reliability = await getPatternReliability(resolvedOrigin, args.pattern);
1408
+ return { content: [{ type: "text", text: JSON.stringify({ origin: resolvedOrigin, pattern: args.pattern, reliability }) }] };
1409
+ }
1410
+ const profile = await getSiteProfile(resolvedOrigin);
1411
+ if (!profile) {
1412
+ return { content: [{ type: "text", text: JSON.stringify({ origin: resolvedOrigin, known: false, message: "No site profile found. This site has not been profiled yet." }) }] };
1413
+ }
1414
+ const hint = formatSiteProfileHint(profile);
1415
+ return { content: [{ type: "text", text: JSON.stringify({ ...profile, _hint: hint }) }] };
1416
+ });
1417
+ }
1418
+ if (args.pattern) {
1419
+ const reliability = await getPatternReliability(resolvedOrigin, args.pattern);
1420
+ return { content: [{ type: "text", text: JSON.stringify({ origin: resolvedOrigin, pattern: args.pattern, reliability }) }] };
1421
+ }
1422
+ const profile = await getSiteProfile(resolvedOrigin);
1423
+ if (!profile) {
1424
+ return { content: [{ type: "text", text: JSON.stringify({ origin: resolvedOrigin, known: false, message: "No site profile found. This site has not been profiled yet." }) }] };
1425
+ }
1426
+ const hint = formatSiteProfileHint(profile);
1427
+ return { content: [{ type: "text", text: JSON.stringify({ ...profile, _hint: hint }) }] };
1428
+ }
1429
+ if (!args.origin)
1430
+ return { content: [{ type: "text", text: JSON.stringify({ error: "origin is required for write actions" }) }] };
1431
+ if (args.action === "learn") {
1432
+ if (!args.name)
1433
+ return { content: [{ type: "text", text: JSON.stringify({ error: "name is required for action='learn'" }) }] };
1434
+ const pattern = await learnPattern(args.origin, args.name, {
1435
+ steps: args.steps ?? [],
1436
+ success_indicator: args.success_indicator,
1437
+ entry: args.entry,
1438
+ });
1439
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, pattern }) }] };
1440
+ }
1441
+ if (args.action === "record_execution") {
1442
+ if (!args.name)
1443
+ return { content: [{ type: "text", text: JSON.stringify({ error: "name is required for action='record_execution'" }) }] };
1444
+ if (args.success === undefined)
1445
+ return { content: [{ type: "text", text: JSON.stringify({ error: "success (boolean) is required for action='record_execution'" }) }] };
1446
+ const updated = await recordPatternExecution(args.origin, args.name, args.success, args.duration_ms);
1447
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, pattern: updated }) }] };
1448
+ }
1449
+ if (args.action === "record_quirk") {
1450
+ if (!args.quirk_key)
1451
+ return { content: [{ type: "text", text: JSON.stringify({ error: "quirk_key is required for action='record_quirk'" }) }] };
1452
+ if (!args.quirk_data)
1453
+ return { content: [{ type: "text", text: JSON.stringify({ error: "quirk_data is required for action='record_quirk'" }) }] };
1454
+ await recordQuirk(args.origin, args.quirk_key, args.quirk_data);
1455
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, origin: args.origin, quirk_key: args.quirk_key }) }] };
1456
+ }
1457
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Unknown action" }) }] };
1209
1458
  }
1210
1459
  catch (e) {
1211
1460
  return wrapError(e);
1212
1461
  }
1213
1462
  });
1214
- server.tool("github_get_pr", "Interact with GitHub pull requests.", {
1215
- number: z.number().optional().describe("Specific pull request number to fetch details, changed files, and comments for. If omitted, lists recent PRs.")
1463
+ // ── GitHub Tool (v2.5.0 Consolidated) ──────────────────────────────────
1464
+ server.tool("browser_github", "Interact with GitHub repositories without burning browser turns. Analyze repo trees, read raw source files, search/inspect issues, and examine PR diffs and review comments.", {
1465
+ action: z.enum(["analyze", "read", "issues", "pr"]).describe("GitHub operation: 'analyze' (repo structure & README), 'read' (raw file contents), 'issues' (list/search/inspect issues), 'pr' (PR details, diff files & comments)."),
1466
+ repo: z.string().optional().describe("Optional 'owner/repo' slug or GitHub URL. If omitted, uses active tab's repository."),
1467
+ paths: z.array(z.string()).optional().describe("For action: 'read'. File paths relative to repo root."),
1468
+ line_range: z.array(z.number()).length(2).optional().describe("For action: 'read'. Optional [start, end] line range (1-indexed)."),
1469
+ number: z.number().optional().describe("For action: 'issues' or 'pr'. Issue or PR number to fetch details and comments."),
1470
+ search: z.string().optional().describe("For action: 'issues'. Search query to find issues."),
1471
+ labels: z.array(z.string()).optional().describe("For action: 'issues'. Filter issues by labels."),
1472
+ state: z.enum(["open", "closed", "all"]).optional().describe("For action: 'issues'. Filter issues by state."),
1473
+ limit: z.number().optional().describe("For action: 'issues'. Max number of issues to list (default 5).")
1216
1474
  }, async (args) => {
1217
1475
  try {
1218
- return await withSecurity(config, "github_get_pr", async (session) => {
1219
- return await githubGetPr(session, args);
1476
+ return await withSecurity(config, "browser_github", async (session, liveConfig) => {
1477
+ return await browserGithub(session, args, liveConfig);
1220
1478
  });
1221
1479
  }
1222
1480
  catch (e) {
@@ -1285,17 +1543,33 @@ DO NOT use browser_scrape or browser_map for binary documents — they cannot re
1285
1543
  }).optional().describe("Override geolocation coordinates"),
1286
1544
  timezoneId: z.string().optional().describe("Override browser timezone (e.g. 'America/New_York')"),
1287
1545
  userAgent: z.string().optional().describe("Override User Agent string"),
1546
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1288
1547
  }, async (args) => {
1289
1548
  try {
1290
1549
  return await withSecurity(config, "browser_emulate", async (session, liveConfig) => {
1291
1550
  return await browserEmulate(session, args, liveConfig);
1292
- });
1551
+ }, false, args.app);
1552
+ }
1553
+ catch (e) {
1554
+ return wrapError(e);
1555
+ }
1556
+ });
1557
+ // ── 45. browser_checkpoint ─────────────────────────────────────────────────
1558
+ server.tool("browser_checkpoint", "Snapshot and restore browser state (cookies, localStorage, sessionStorage, and scroll/URL) into named checkpoints to time-travel and rollback failed automation branches.", {
1559
+ action: z.enum(["save", "restore", "list", "delete"]).describe("Action to perform: 'save' a new checkpoint, 'restore' an existing checkpoint, 'list' all checkpoints, or 'delete' a checkpoint."),
1560
+ name: z.string().optional().describe("Checkpoint name identifier (required for 'save', 'restore', 'delete')."),
1561
+ includeDom: z.boolean().optional().describe("Whether to include a lightweight DOM snapshot during 'save' (default false)."),
1562
+ app: z.string().optional().describe("Electron app name configured in config.json under 'electron'."),
1563
+ }, async (args) => {
1564
+ try {
1565
+ return await withSecurity(config, "browser_checkpoint", async (session, liveConfig) => {
1566
+ return await browserCheckpoint(session, args, liveConfig);
1567
+ }, false, args.app);
1293
1568
  }
1294
1569
  catch (e) {
1295
1570
  return wrapError(e);
1296
1571
  }
1297
1572
  });
1298
- // ── Prompts ──────────────────────────────────────────────────────────────────
1299
1573
  // page_context: zero-shot context injection. Invoke with /page_context
1300
1574
  // in any MCP client that supports prompts (Claude Desktop, etc.).
1301
1575
  server.prompt("page_context", "Analyze the current active tab. Use this to give the agent instant context about the page without wasting tool calls.", async () => {
@@ -1409,29 +1683,35 @@ DO NOT use browser_scrape or browser_map for binary documents — they cannot re
1409
1683
  }]
1410
1684
  };
1411
1685
  });
1412
- // ── Plugins Lifecycle (Add / Override / Remove) ───────────────────────────
1413
- const plugins = discoverPlugins();
1686
+ // ── Plugins Lifecycle & Dynamic Registry ───────────────────────────
1414
1687
  const { getVerifiedPackages, resolvePluginTrust } = await import("./plugin/registry.js");
1415
1688
  const verifiedPackages = await getVerifiedPackages();
1416
- let pluginAddedCount = 0;
1417
- let pluginOverrideCount = 0;
1418
- let pluginRemovedCount = 0;
1419
- for (const plugin of plugins) {
1689
+ const loadedPluginNames = new Set();
1690
+ const registerPluginTools = async (plugin) => {
1691
+ if (loadedPluginNames.has(plugin.name))
1692
+ return;
1420
1693
  const trust = resolvePluginTrust(plugin.name, verifiedPackages);
1421
1694
  const isOfficial = trust.tier === "official";
1422
1695
  const addedNames = [];
1423
1696
  const overriddenNames = [];
1424
1697
  const removedNames = [];
1698
+ // Register any hooks declared in the plugin
1699
+ if (plugin.hooks) {
1700
+ hookPipeline.registerPluginHooks(plugin.name, plugin.hooks);
1701
+ }
1425
1702
  // 1. Tool Removals (disable built-in tools)
1703
+ const manifestPerms = plugin.manifest?.permissions;
1704
+ const manifestToolsRemove = Array.isArray(manifestPerms)
1705
+ ? []
1706
+ : manifestPerms?.tools?.remove || [];
1426
1707
  const toRemove = [
1427
1708
  ...(plugin.removeTools || []),
1428
- ...(plugin.manifest?.permissions?.tools?.remove || []),
1709
+ ...manifestToolsRemove,
1429
1710
  ];
1430
1711
  for (const toolName of toRemove) {
1431
1712
  if (server._registeredTools?.[toolName]) {
1432
1713
  delete server._registeredTools[toolName];
1433
1714
  removedNames.push(toolName);
1434
- pluginRemovedCount++;
1435
1715
  }
1436
1716
  }
1437
1717
  // 2. Tool Overrides (replace or wrap built-in tools)
@@ -1447,13 +1727,15 @@ DO NOT use browser_scrape or browser_map for binary documents — they cannot re
1447
1727
  try {
1448
1728
  return await withSecurity(config, ov.name, async (session, liveConfig) => {
1449
1729
  const wrappedOrig = existingTool
1450
- ? async (origArgs, _sess, _cfg) => {
1451
- const res = await existingTool.handler(origArgs, {});
1730
+ ? async (origArgs, origSess, origCfg) => {
1731
+ const finalSess = origSess || session;
1732
+ const finalCfg = origCfg || liveConfig;
1733
+ const res = await existingTool.handler(origArgs, finalSess, finalCfg);
1452
1734
  return res;
1453
1735
  }
1454
1736
  : undefined;
1455
1737
  const result = await ov.handler(args, session, liveConfig, wrappedOrig);
1456
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
1738
+ return result;
1457
1739
  }, isOfficial);
1458
1740
  }
1459
1741
  catch (e) {
@@ -1461,19 +1743,19 @@ DO NOT use browser_scrape or browser_map for binary documents — they cannot re
1461
1743
  }
1462
1744
  });
1463
1745
  overriddenNames.push(ov.name);
1464
- pluginOverrideCount++;
1465
1746
  }
1466
1747
  // 3. Tool Additions (new tools)
1467
1748
  const newTools = plugin.tools || [];
1468
1749
  for (const td of newTools) {
1469
1750
  if (server._registeredTools?.[td.name]) {
1751
+ process.stderr.write(` \x1b[33m[!] Plugin ${plugin.name} registered tool "${td.name}" which replaced an existing tool.\x1b[0m\n`);
1470
1752
  delete server._registeredTools[td.name];
1471
1753
  }
1472
- server.tool(td.name, td.description, td.schema, async (args) => {
1754
+ server.tool(td.name, td.description, td.schema || td.parameters || {}, async (args) => {
1473
1755
  try {
1474
1756
  return await withSecurity(config, td.name, async (session, liveConfig) => {
1475
1757
  const result = await td.handler(args, session, liveConfig);
1476
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
1758
+ return result;
1477
1759
  }, isOfficial);
1478
1760
  }
1479
1761
  catch (e) {
@@ -1481,45 +1763,105 @@ DO NOT use browser_scrape or browser_map for binary documents — they cannot re
1481
1763
  }
1482
1764
  });
1483
1765
  addedNames.push(td.name);
1484
- pluginAddedCount++;
1485
1766
  }
1486
- // Execute plugin onLoad hook if present
1767
+ // Pass the rich SDK context so dynamic tool registrations receive full security & session
1768
+ const fullPluginCtx = plugin._context;
1769
+ if (fullPluginCtx && fullPluginCtx.mcp) {
1770
+ fullPluginCtx.mcp.registerTool = (tool) => {
1771
+ if (server._registeredTools?.[tool.name]) {
1772
+ process.stderr.write(` \x1b[33m[!] Plugin ${plugin.name} dynamic tool "${tool.name}" replaced an existing tool.\x1b[0m\n`);
1773
+ delete server._registeredTools[tool.name];
1774
+ }
1775
+ server.tool(tool.name, tool.description || "", tool.schema || tool.parameters || {}, async (args) => {
1776
+ return await withSecurity(config, tool.name, async (session, liveConfig) => {
1777
+ const result = await tool.handler(args, session, liveConfig);
1778
+ return result;
1779
+ }, isOfficial);
1780
+ });
1781
+ };
1782
+ fullPluginCtx.mcp.overrideTool = (toolName, overrideHandler) => {
1783
+ const existingTool = server._registeredTools?.[toolName];
1784
+ if (existingTool) {
1785
+ delete server._registeredTools[toolName];
1786
+ }
1787
+ server.tool(toolName, `Tool overridden by ${plugin.name}`, {}, async (args) => {
1788
+ return await withSecurity(config, toolName, async (session, liveConfig) => {
1789
+ const wrappedOrig = existingTool
1790
+ ? async (origArgs, origSess, origCfg) => {
1791
+ const finalSess = origSess || session;
1792
+ const finalCfg = origCfg || liveConfig;
1793
+ return await existingTool.handler(origArgs, finalSess, finalCfg);
1794
+ }
1795
+ : undefined;
1796
+ const result = await overrideHandler(args, session, liveConfig, wrappedOrig);
1797
+ return result;
1798
+ }, isOfficial);
1799
+ });
1800
+ };
1801
+ fullPluginCtx.mcp.removeTool = (toolName) => {
1802
+ if (server._registeredTools?.[toolName]) {
1803
+ delete server._registeredTools[toolName];
1804
+ }
1805
+ };
1806
+ fullPluginCtx.mcp.unregisterTool = (toolName) => {
1807
+ if (server._registeredTools?.[toolName]) {
1808
+ delete server._registeredTools[toolName];
1809
+ }
1810
+ };
1811
+ }
1487
1812
  if (typeof plugin.onLoad === "function") {
1488
1813
  try {
1489
- await plugin.onLoad();
1814
+ await plugin.onLoad(fullPluginCtx || plugin._context);
1490
1815
  }
1491
1816
  catch (err) {
1492
1817
  process.stderr.write(` \u001b[31m[!] Plugin ${plugin.name} onLoad failed: ${err?.message}\u001b[0m\n`);
1493
1818
  }
1494
1819
  }
1495
- // Build permission and operations warning string
1496
- const ops = [];
1497
- if (addedNames.length > 0)
1498
- ops.push(`+${addedNames.length} added`);
1499
- if (overriddenNames.length > 0)
1500
- ops.push(`⚡overrode [${overriddenNames.join(", ")}]`);
1501
- if (removedNames.length > 0)
1502
- ops.push(`✖removed [${removedNames.join(", ")}]`);
1503
- const opsStr = ops.length > 0 ? ` (${ops.join(", ")})` : "";
1504
- let permWarning = "";
1505
- if (trust.tier === "unverified" && plugin.manifest?.permissions) {
1506
- const perms = plugin.manifest.permissions;
1507
- const flags = [];
1508
- if (perms.network)
1509
- flags.push("network");
1510
- if (perms.cdp)
1511
- flags.push("cdp");
1512
- if (perms.fs)
1513
- flags.push("fs");
1514
- if (perms.tools?.override?.length)
1515
- flags.push(`override:${perms.tools.override.join(",")}`);
1516
- if (perms.tools?.remove?.length)
1517
- flags.push(`remove:${perms.tools.remove.join(",")}`);
1518
- if (flags.length > 0) {
1519
- permWarning = ` \u001b[33m— requests: ${flags.join(", ")}\u001b[0m`;
1820
+ loadedPluginNames.add(plugin.name);
1821
+ activeLoadedPlugins.set(plugin.name, plugin);
1822
+ process.stderr.write(` [Plugin Loaded] ${plugin.name} v${plugin.version}\n`);
1823
+ // Notify the client that tools have changed
1824
+ try {
1825
+ if (server.server && typeof server.server.sendToolListChanged === "function") {
1826
+ server.server.sendToolListChanged();
1520
1827
  }
1521
1828
  }
1522
- process.stderr.write(` ${trust.badge} ${plugin.name} v${plugin.version}${opsStr}${permWarning}\n`);
1829
+ catch {
1830
+ // Ignore if client doesn't support it or not connected yet
1831
+ }
1832
+ };
1833
+ // Add the built-in ecosystem tools
1834
+ server.tool("list_plugins", "List all available Weavetab plugins that can be loaded.", {}, async () => {
1835
+ const installed = listInstalledPlugins();
1836
+ const available = installed
1837
+ .filter(info => !loadedPluginNames.has(info.packageName))
1838
+ .map(info => `- ${info.packageName}: ${info.manifest?.description || "No description"}`);
1839
+ if (available.length === 0) {
1840
+ return { content: [{ type: "text", text: "No un-loaded plugins available." }] };
1841
+ }
1842
+ return { content: [{ type: "text", text: `Available plugins:\n${available.join("\n")}\n\nUse load_plugin with the plugin package name to load one.` }] };
1843
+ });
1844
+ server.tool("load_plugin", "Load any Weavetab plugin dynamically by npm package name (e.g. '@fy2ne/wt-youtube', 'my-plugin').", {
1845
+ name: z.string().describe("The exact npm package name of the plugin to load (e.g. '@fy2ne/wt-youtube', 'my-plugin')")
1846
+ }, async ({ name }) => {
1847
+ if (loadedPluginNames.has(name)) {
1848
+ return { content: [{ type: "text", text: `Plugin ${name} is already loaded.` }] };
1849
+ }
1850
+ const plugin = loadPluginByName(name);
1851
+ if (!plugin) {
1852
+ return { content: [{ type: "text", text: `Plugin "${name}" not found. Make sure it is installed via "weavetab plugin add ${name}".` }] };
1853
+ }
1854
+ await registerPluginTools(plugin);
1855
+ const toolsList = (plugin.tools || []).map((t) => t.name).join(", ");
1856
+ return { content: [{ type: "text", text: `loaded plugin ${name}. Tools registered: ${toolsList || "(dynamic tools registered)"}` }] };
1857
+ });
1858
+ // Auto-load plugins by default unless explicitly marked with lazy: true
1859
+ const allPlugins = discoverPlugins();
1860
+ for (const plugin of allPlugins) {
1861
+ const isExplicitlyLazy = plugin.manifest?.lazy === true;
1862
+ if (!isExplicitlyLazy) {
1863
+ await registerPluginTools(plugin);
1864
+ }
1523
1865
  }
1524
1866
  let transport;
1525
1867
  if (transportMode === "ws") {
@@ -1570,6 +1912,28 @@ async function cleanup() {
1570
1912
  if (cleanupCalled)
1571
1913
  return;
1572
1914
  cleanupCalled = true;
1915
+ // Run plugin onUnload hooks and clear hook pipeline
1916
+ try {
1917
+ for (const plugin of activeLoadedPlugins.values()) {
1918
+ if (typeof plugin.onUnload === "function") {
1919
+ try {
1920
+ plugin.onUnload(plugin._context);
1921
+ }
1922
+ catch (e) {
1923
+ process.stderr.write(`[Weavetab] Error in plugin "${plugin.name}" onUnload: ${e?.message || e}\n`);
1924
+ }
1925
+ }
1926
+ }
1927
+ activeLoadedPlugins.clear();
1928
+ hookPipeline.clearAllHooks();
1929
+ }
1930
+ catch { /* non-fatal */ }
1931
+ // Stop config file watcher handle
1932
+ try {
1933
+ const { stopConfigWatcher } = await import("./config/loader.js");
1934
+ stopConfigWatcher();
1935
+ }
1936
+ catch { /* non-fatal */ }
1573
1937
  if (global.__wt_is_server) {
1574
1938
  // In server mode the browser must survive server restarts. Only cancel
1575
1939
  // analytics intervals and close CDP WebSocket connections. Never call