leapfrog-mcp 0.6.2 → 0.6.3

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 (57) hide show
  1. package/dist/adaptive-wait.d.ts +72 -0
  2. package/dist/adaptive-wait.js +695 -0
  3. package/dist/api-intelligence.d.ts +82 -0
  4. package/dist/api-intelligence.js +575 -0
  5. package/dist/captcha-solver.d.ts +26 -0
  6. package/dist/captcha-solver.js +547 -0
  7. package/dist/cdp-connector.d.ts +33 -0
  8. package/dist/cdp-connector.js +176 -0
  9. package/dist/consent-dismiss.d.ts +33 -0
  10. package/dist/consent-dismiss.js +358 -0
  11. package/dist/crash-recovery.d.ts +74 -0
  12. package/dist/crash-recovery.js +242 -0
  13. package/dist/domain-knowledge.d.ts +149 -0
  14. package/dist/domain-knowledge.js +449 -0
  15. package/dist/harness-intelligence.d.ts +65 -0
  16. package/dist/harness-intelligence.js +432 -0
  17. package/dist/humanize-fingerprint.d.ts +42 -0
  18. package/dist/humanize-fingerprint.js +161 -0
  19. package/dist/humanize-mouse.d.ts +95 -0
  20. package/dist/humanize-mouse.js +275 -0
  21. package/dist/humanize-pause.d.ts +48 -0
  22. package/dist/humanize-pause.js +111 -0
  23. package/dist/humanize-scroll.d.ts +67 -0
  24. package/dist/humanize-scroll.js +185 -0
  25. package/dist/humanize-typing.d.ts +60 -0
  26. package/dist/humanize-typing.js +258 -0
  27. package/dist/humanize-utils.d.ts +62 -0
  28. package/dist/humanize-utils.js +100 -0
  29. package/dist/intervention.d.ts +65 -0
  30. package/dist/intervention.js +591 -0
  31. package/dist/logger.d.ts +13 -0
  32. package/dist/logger.js +47 -0
  33. package/dist/network-intelligence.d.ts +70 -0
  34. package/dist/network-intelligence.js +424 -0
  35. package/dist/page-classifier.d.ts +33 -0
  36. package/dist/page-classifier.js +1000 -0
  37. package/dist/paginate.d.ts +42 -0
  38. package/dist/paginate.js +693 -0
  39. package/dist/recording.d.ts +72 -0
  40. package/dist/recording.js +934 -0
  41. package/dist/script-executor.d.ts +31 -0
  42. package/dist/script-executor.js +249 -0
  43. package/dist/session-hud.d.ts +20 -0
  44. package/dist/session-hud.js +134 -0
  45. package/dist/snapshot-differ.d.ts +26 -0
  46. package/dist/snapshot-differ.js +225 -0
  47. package/dist/ssrf.d.ts +28 -0
  48. package/dist/ssrf.js +290 -0
  49. package/dist/stealth-audit.d.ts +27 -0
  50. package/dist/stealth-audit.js +719 -0
  51. package/dist/stealth.d.ts +195 -0
  52. package/dist/stealth.js +1157 -0
  53. package/dist/tab-manager.d.ts +14 -0
  54. package/dist/tab-manager.js +306 -0
  55. package/dist/tiles-coordinator.d.ts +106 -0
  56. package/dist/tiles-coordinator.js +358 -0
  57. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ import type { BrowserContext, Page } from "playwright-core";
2
+ import type { Session, TabInfo, WaitCondition } from "./types.js";
3
+ export declare class TabManager {
4
+ attachToContext(context: BrowserContext, session: Session): void;
5
+ getActivePage(session: Session): Page;
6
+ listTabs(session: Session): Promise<TabInfo[]>;
7
+ switchTab(session: Session, tabIndex: number): Page;
8
+ closeTab(session: Session, tabIndex?: number): Promise<Page>;
9
+ waitFor(page: Page, session: Session, condition: WaitCondition): Promise<void>;
10
+ private pruneClosedPages;
11
+ }
12
+ /** Singleton instance for use across the server */
13
+ export declare const tabManager: TabManager;
14
+ export default TabManager;
@@ -0,0 +1,306 @@
1
+ import { stealth } from "./stealth.js";
2
+ // ─── Tab Manager ──────────────────────────────────────────────────────────
3
+ //
4
+ // Manages multiple tabs (pages) within a single browser session.
5
+ // New tabs auto-become active (best UX for OAuth/popup flows).
6
+ // Closed pages are auto-cleaned from the array on list/switch operations.
7
+ // getActivePage() is the single source of truth for all tool operations.
8
+ //
9
+ const MAX_WAIT_TIMEOUT = 30_000;
10
+ const DEFAULT_WAIT_TIMEOUT = 10_000;
11
+ /**
12
+ * Ensure session.pages and session.activePageIndex are initialized.
13
+ * Safe to call multiple times — only initializes on first call or if
14
+ * the session was created before TabManager was wired up.
15
+ */
16
+ function ensureInitialized(session) {
17
+ if (!session.pages) {
18
+ session.pages = [session.page];
19
+ }
20
+ if (session.activePageIndex === undefined || session.activePageIndex === null) {
21
+ session.activePageIndex = 0;
22
+ }
23
+ return { pages: session.pages, activePageIndex: session.activePageIndex };
24
+ }
25
+ export class TabManager {
26
+ // ── attachToContext ─────────────────────────────────────────────────
27
+ //
28
+ // Call once when a session is created. Initializes the pages array from
29
+ // the session's initial page and sets up event handlers to auto-track
30
+ // new tabs/popups opened by the browser context.
31
+ attachToContext(context, session) {
32
+ // Initialize pages array with the session's existing page
33
+ session.pages = [session.page];
34
+ session.activePageIndex = 0;
35
+ // Listen for new pages (popups, window.open, target=_blank links)
36
+ // BUG-002: Do NOT auto-switch activePageIndex to popup — keep the original
37
+ // page active. Popups are tracked but the caller must explicitly switch.
38
+ context.on("page", (newPage) => {
39
+ const { pages } = ensureInitialized(session);
40
+ // Add to pages array (but do NOT change activePageIndex)
41
+ pages.push(newPage);
42
+ // Auto-dismiss dialogs on the new page (same pattern as session-manager)
43
+ // P1 #6: Add 200-500ms random delay — instant dismiss (< 30ms) is a headless signal
44
+ newPage.on("dialog", (dialog) => {
45
+ const delay = stealth.isEnabled() ? stealth.getDialogDelay() : 0;
46
+ setTimeout(() => dialog.dismiss().catch(() => { }), delay);
47
+ });
48
+ // When this page closes, clean it from the array
49
+ newPage.on("close", () => {
50
+ this.pruneClosedPages(session);
51
+ });
52
+ });
53
+ }
54
+ // ── getActivePage ──────────────────────────────────────────────────
55
+ //
56
+ // Single source of truth for the current active page. All tools should
57
+ // call this instead of accessing session.page directly.
58
+ // Prunes closed pages and falls back to the first open page if the
59
+ // active index has become invalid.
60
+ getActivePage(session) {
61
+ this.pruneClosedPages(session);
62
+ const { pages } = ensureInitialized(session);
63
+ let idx = session.activePageIndex;
64
+ // Validate index bounds after pruning
65
+ if (idx < 0 || idx >= pages.length) {
66
+ idx = 0;
67
+ session.activePageIndex = 0;
68
+ }
69
+ const page = pages[idx];
70
+ // Keep session.page in sync for backward compatibility
71
+ if (page && session.page !== page) {
72
+ session.page = page;
73
+ }
74
+ return page;
75
+ }
76
+ // ── listTabs ───────────────────────────────────────────────────────
77
+ //
78
+ // Returns info about all open tabs. Cleans up closed pages first.
79
+ // Each entry includes index, url, title, and active status.
80
+ async listTabs(session) {
81
+ this.pruneClosedPages(session);
82
+ const { pages, activePageIndex } = ensureInitialized(session);
83
+ const tabs = [];
84
+ for (let i = 0; i < pages.length; i++) {
85
+ const page = pages[i];
86
+ const isActive = i === activePageIndex;
87
+ let url = "";
88
+ let title = "";
89
+ try {
90
+ url = page.url();
91
+ }
92
+ catch {
93
+ // Page may have crashed between prune and access
94
+ url = "(unavailable)";
95
+ }
96
+ try {
97
+ title = await page.title();
98
+ }
99
+ catch {
100
+ title = "(unavailable)";
101
+ }
102
+ tabs.push({ index: i, url, title, isActive });
103
+ }
104
+ return tabs;
105
+ }
106
+ // ── switchTab ──────────────────────────────────────────────────────
107
+ //
108
+ // Switch the active page by index. Pass -1 to switch to the last
109
+ // (most recently opened) tab. Validates bounds and brings the
110
+ // target page to front.
111
+ switchTab(session, tabIndex) {
112
+ this.pruneClosedPages(session);
113
+ const { pages } = ensureInitialized(session);
114
+ const count = pages.length;
115
+ if (count === 0) {
116
+ throw new Error("No open tabs in session.");
117
+ }
118
+ // -1 means "last tab" (most recently opened)
119
+ const resolvedIndex = tabIndex === -1 ? count - 1 : tabIndex;
120
+ if (resolvedIndex < 0 || resolvedIndex >= count) {
121
+ throw new Error(`Tab index ${tabIndex} out of bounds. Valid range: 0-${count - 1} (or -1 for last).`);
122
+ }
123
+ const previousIndex = session.activePageIndex;
124
+ session.activePageIndex = resolvedIndex;
125
+ const page = pages[resolvedIndex];
126
+ // Keep session.page in sync for backward compatibility
127
+ session.page = page;
128
+ // BUG FIX: Invalidate snapshot refs when switching to a different tab.
129
+ // Refs from tab A's DOM are meaningless on tab B. Use the same stale-ref
130
+ // pattern as navigation: bump navGeneration and set staleRefThreshold so
131
+ // the act/extract handlers reject old refs and require a fresh snapshot.
132
+ if (resolvedIndex !== previousIndex) {
133
+ session.staleRefThreshold = session.refCounter;
134
+ session.navGeneration = (session.navGeneration ?? 0) + 1;
135
+ }
136
+ // Bring to front (non-blocking — fire and forget)
137
+ page.bringToFront().catch(() => { });
138
+ return page;
139
+ }
140
+ // ── closeTab ───────────────────────────────────────────────────────
141
+ //
142
+ // Close a tab by index (default: active tab). Cannot close the last
143
+ // remaining tab. If closing the active tab, switches to the previous
144
+ // tab or the first available one.
145
+ async closeTab(session, tabIndex) {
146
+ this.pruneClosedPages(session);
147
+ const { pages, activePageIndex } = ensureInitialized(session);
148
+ const count = pages.length;
149
+ if (count <= 1) {
150
+ throw new Error("Cannot close the last remaining tab.");
151
+ }
152
+ // Default to active tab if no index specified
153
+ const targetIndex = tabIndex ?? activePageIndex;
154
+ if (targetIndex < 0 || targetIndex >= count) {
155
+ throw new Error(`Tab index ${targetIndex} out of bounds. Valid range: 0-${count - 1}.`);
156
+ }
157
+ const targetPage = pages[targetIndex];
158
+ // Remove from array first (before closing, to handle race conditions)
159
+ pages.splice(targetIndex, 1);
160
+ // Determine new active index
161
+ if (targetIndex === activePageIndex) {
162
+ // Closing the active tab — prefer previous tab, clamp to valid range
163
+ session.activePageIndex = Math.min(Math.max(targetIndex - 1, 0), pages.length - 1);
164
+ }
165
+ else if (targetIndex < activePageIndex) {
166
+ // Closing a tab before the active one — adjust index to keep same page active
167
+ session.activePageIndex = activePageIndex - 1;
168
+ }
169
+ // If closing a tab after the active one, activePageIndex stays the same
170
+ // Close the page (async, may throw if already closed)
171
+ try {
172
+ await targetPage.close();
173
+ }
174
+ catch {
175
+ // Page may already be closed — safe to ignore
176
+ }
177
+ const newActivePage = pages[session.activePageIndex];
178
+ // Keep session.page in sync
179
+ session.page = newActivePage;
180
+ // Bring the new active page to front
181
+ newActivePage.bringToFront().catch(() => { });
182
+ return newActivePage;
183
+ }
184
+ // ── waitFor ────────────────────────────────────────────────────────
185
+ //
186
+ // Unified smart wait supporting multiple condition types:
187
+ // - element: wait for a CSS selector or @eN ref to be visible
188
+ // - text: wait for text content to appear (optionally scoped to target)
189
+ // - network_idle: wait for network to settle
190
+ // - navigation: wait for URL to match a pattern
191
+ // - js: wait for a JS expression to return truthy
192
+ async waitFor(page, session, condition) {
193
+ const timeout = Math.min(condition.timeout ?? DEFAULT_WAIT_TIMEOUT, MAX_WAIT_TIMEOUT);
194
+ // Resolve @eN refs to selectors (with stale-ref detection)
195
+ const resolveTarget = (ref) => {
196
+ if (ref.startsWith("@e")) {
197
+ if ((session.navGeneration ?? 0) > (session.refNavGeneration ?? 0)) {
198
+ throw new Error(`Ref ${ref} is stale — the page has navigated since the last snapshot. Take a new snapshot to get updated refs.`);
199
+ }
200
+ const selector = session.refMap.get(ref);
201
+ if (!selector) {
202
+ throw new Error(`Ref ${ref} not found. Take a fresh snapshot first.`);
203
+ }
204
+ return selector;
205
+ }
206
+ return ref; // CSS selectors don't go stale
207
+ };
208
+ switch (condition.type) {
209
+ case "element": {
210
+ if (!condition.target) {
211
+ throw new Error("'element' wait requires a target (CSS selector or @eN ref).");
212
+ }
213
+ const selector = resolveTarget(condition.target);
214
+ await page.locator(selector).waitFor({ state: "visible", timeout });
215
+ break;
216
+ }
217
+ case "text": {
218
+ if (!condition.text) {
219
+ throw new Error("'text' wait requires a text string.");
220
+ }
221
+ if (condition.target) {
222
+ // Scoped to a specific element
223
+ const selector = resolveTarget(condition.target);
224
+ await page
225
+ .locator(selector)
226
+ .getByText(condition.text)
227
+ .waitFor({ timeout });
228
+ }
229
+ else {
230
+ // Page-level text search
231
+ await page.getByText(condition.text).waitFor({ timeout });
232
+ }
233
+ break;
234
+ }
235
+ case "network_idle": {
236
+ await page.waitForLoadState("networkidle", { timeout });
237
+ break;
238
+ }
239
+ case "navigation": {
240
+ const urlPattern = condition.text || "**";
241
+ await page.waitForURL(urlPattern, { timeout });
242
+ break;
243
+ }
244
+ case "js": {
245
+ if (!condition.js) {
246
+ throw new Error("'js' wait requires a js expression.");
247
+ }
248
+ await page.waitForFunction(condition.js, undefined, { timeout });
249
+ break;
250
+ }
251
+ default: {
252
+ const _exhaustive = condition.type;
253
+ throw new Error(`Unknown wait condition type: ${_exhaustive}`);
254
+ }
255
+ }
256
+ }
257
+ // ── Internal: pruneClosedPages ─────────────────────────────────────
258
+ //
259
+ // Remove closed pages from session.pages and adjust activePageIndex.
260
+ // Called before any read/write operation on the pages array.
261
+ pruneClosedPages(session) {
262
+ const { pages, activePageIndex } = ensureInitialized(session);
263
+ const activePage = activePageIndex >= 0 && activePageIndex < pages.length
264
+ ? pages[activePageIndex]
265
+ : null;
266
+ // Filter out closed pages
267
+ const openPages = pages.filter((p) => !p.isClosed());
268
+ if (openPages.length === pages.length) {
269
+ // Nothing was pruned
270
+ return;
271
+ }
272
+ session.pages = openPages;
273
+ if (openPages.length === 0) {
274
+ // BUG-002: All pages closed — create a recovery page in the same context
275
+ // so the session stays usable instead of becoming a zombie.
276
+ session.activePageIndex = 0;
277
+ session.context.newPage().then((recoveryPage) => {
278
+ if (!session.pages)
279
+ session.pages = [];
280
+ session.pages.push(recoveryPage);
281
+ session.page = recoveryPage;
282
+ session.activePageIndex = 0;
283
+ // Auto-dismiss dialogs on recovery page
284
+ recoveryPage.on("dialog", (d) => d.dismiss().catch(() => { }));
285
+ }).catch(() => {
286
+ // Context may be dead — session will fail on next access
287
+ });
288
+ return;
289
+ }
290
+ // Try to keep the same active page
291
+ if (activePage && !activePage.isClosed()) {
292
+ const newIndex = openPages.indexOf(activePage);
293
+ session.activePageIndex = newIndex >= 0 ? newIndex : 0;
294
+ }
295
+ else {
296
+ // Active page was closed — fall back to last available page
297
+ session.activePageIndex = openPages.length - 1;
298
+ }
299
+ // Keep session.page in sync
300
+ session.page = openPages[session.activePageIndex];
301
+ }
302
+ }
303
+ // ─── Exports ──────────────────────────────────────────────────────────────
304
+ /** Singleton instance for use across the server */
305
+ export const tabManager = new TabManager();
306
+ export default TabManager;
@@ -0,0 +1,106 @@
1
+ /** A single tile slot representing one MCP instance's screen region. */
2
+ export interface TileSlot {
3
+ sessionId: string;
4
+ instancePid: number;
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ }
10
+ /** Full grid state persisted to ~/.leapfrog/tiles.json. */
11
+ export interface TilesState {
12
+ slots: TileSlot[];
13
+ screenWidth: number;
14
+ screenHeight: number;
15
+ lastUpdated: number;
16
+ }
17
+ /**
18
+ * Coordinates screen real estate across multiple Leapfrog MCP instances
19
+ * running in separate terminals.
20
+ *
21
+ * All coordination happens through the filesystem — no sockets, no ports,
22
+ * no IPC. State is stored in `~/.leapfrog/tiles.json` and protected by
23
+ * a simple file lock (`~/.leapfrog/tiles.lock`).
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const tiles = new TilesCoordinator(1920, 1080);
28
+ * const pos = await tiles.claimSlot("session-abc");
29
+ * // pos = { x: 0, y: 0, width: 960, height: 540 }
30
+ *
31
+ * // On shutdown:
32
+ * await tiles.releaseAll();
33
+ * ```
34
+ */
35
+ export declare class TilesCoordinator {
36
+ private screenWidth;
37
+ private screenHeight;
38
+ private mySessions;
39
+ private watcher;
40
+ /**
41
+ * Create a new TilesCoordinator.
42
+ * Creates `~/.leapfrog/` if it doesn't already exist.
43
+ *
44
+ * @param screenWidth Total screen width in pixels.
45
+ * @param screenHeight Total screen height in pixels.
46
+ */
47
+ constructor(screenWidth: number, screenHeight: number);
48
+ /**
49
+ * Update the screen dimensions used for grid calculations.
50
+ * Call this after detecting the real screen size (e.g., via CDP maximize).
51
+ * Triggers a recalculation of all slot positions in the shared state.
52
+ */
53
+ updateScreenSize(width: number, height: number): Promise<void>;
54
+ /**
55
+ * Claim a grid slot for the given session.
56
+ *
57
+ * Dead-PID slots are reaped first, then the new session is appended
58
+ * and all positions are recalculated to fill the grid evenly.
59
+ *
60
+ * @param sessionId Unique identifier for this session.
61
+ * @returns The assigned tile position and dimensions.
62
+ */
63
+ claimSlot(sessionId: string): Promise<{
64
+ x: number;
65
+ y: number;
66
+ width: number;
67
+ height: number;
68
+ }>;
69
+ /**
70
+ * Release a slot, removing it from the shared grid.
71
+ * Remaining slots are repositioned to fill the gap.
72
+ *
73
+ * @param sessionId The session to release.
74
+ */
75
+ releaseSlot(sessionId: string): Promise<void>;
76
+ /**
77
+ * Get the current grid layout including all live instances.
78
+ *
79
+ * @returns A snapshot of the shared tiling state.
80
+ */
81
+ getLayout(): Promise<TilesState>;
82
+ /**
83
+ * Remove slots whose owning process is no longer running.
84
+ * Called automatically on `claimSlot`, but can be invoked directly
85
+ * for periodic housekeeping.
86
+ *
87
+ * @returns Session IDs of the reaped (dead) slots.
88
+ */
89
+ reapDeadSlots(): Promise<string[]>;
90
+ /**
91
+ * Watch `tiles.json` for changes made by other instances.
92
+ * The callback fires with the new state whenever the file changes.
93
+ *
94
+ * @param onChange Callback invoked with the updated tiling state.
95
+ */
96
+ watch(onChange: (state: TilesState) => void): void;
97
+ /**
98
+ * Stop watching for tile changes from other instances.
99
+ */
100
+ unwatch(): void;
101
+ /**
102
+ * Release all slots owned by this process.
103
+ * Call this on SIGINT / SIGTERM to clean up before exit.
104
+ */
105
+ releaseAll(): Promise<void>;
106
+ }