@wong2kim/wmux 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/dist/cli/cli/commands/browser.js +101 -77
- package/dist/cli/cli/index.js +6 -6
- package/dist/cli/shared/constants.js +3 -0
- package/dist/cli/shared/rpc.js +15 -4
- package/dist/mcp/mcp/index.js +41 -21
- package/dist/mcp/mcp/playwright/PlaywrightEngine.js +186 -0
- package/dist/mcp/mcp/playwright/anti-detection.js +58 -0
- package/dist/mcp/mcp/playwright/dom-intelligence.js +171 -0
- package/dist/mcp/mcp/playwright/human-typing.js +48 -0
- package/dist/mcp/mcp/playwright/markdown-extractor.js +520 -0
- package/dist/mcp/mcp/playwright/snapshot.js +261 -0
- package/dist/mcp/mcp/playwright/tools/extraction.js +143 -0
- package/dist/mcp/mcp/playwright/tools/file.js +274 -0
- package/dist/mcp/mcp/playwright/tools/inspection.js +395 -0
- package/dist/mcp/mcp/playwright/tools/interaction.js +387 -0
- package/dist/mcp/mcp/playwright/tools/navigation.js +183 -0
- package/dist/mcp/mcp/playwright/tools/state.js +410 -0
- package/dist/mcp/mcp/playwright/tools/utility.js +167 -0
- package/dist/mcp/mcp/playwright/tools/wait.js +111 -0
- package/dist/mcp/shared/constants.js +3 -0
- package/dist/mcp/shared/rpc.js +15 -4
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Inspired by [cmux](https://github.com/manaflow-ai/cmux) (macOS), wmux brings the
|
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
**Download:** [wmux-1.1.
|
|
17
|
+
**Download:** [wmux-1.1.2 Setup.exe](https://github.com/openwong2kim/wmux/releases/latest)
|
|
18
18
|
|
|
19
19
|
Or build from source:
|
|
20
20
|
```powershell
|
|
@@ -45,13 +45,14 @@ irm https://raw.githubusercontent.com/openwong2kim/wmux/main/install.ps1 | iex
|
|
|
45
45
|
- **Vi copy mode** — `Ctrl+Shift+X`
|
|
46
46
|
- **Search** — `Ctrl+F`
|
|
47
47
|
- **Unlimited scrollback** — 999,999 lines default
|
|
48
|
+
- **Scrollback persistence** — terminal content saved to disk, restored on restart
|
|
48
49
|
|
|
49
50
|
### Workspaces
|
|
50
51
|
- Sidebar with drag-and-drop reordering
|
|
51
52
|
- `Ctrl+1` ~ `Ctrl+9` quick switch
|
|
52
53
|
- **Multiview** — `Ctrl+click` workspaces to split-view them simultaneously
|
|
53
54
|
- `Ctrl+Shift+G` to exit multiview
|
|
54
|
-
- Session persistence —
|
|
55
|
+
- **Session persistence** — workspace layout, tabs, cwd, and terminal scrollback all restored on restart
|
|
55
56
|
|
|
56
57
|
### Browser
|
|
57
58
|
- Built-in browser panel — `Ctrl+Shift+L`
|
|
@@ -171,20 +172,29 @@ The `install.ps1` script auto-installs Python and VS Build Tools if missing.
|
|
|
171
172
|
|
|
172
173
|
```
|
|
173
174
|
Electron Main Process
|
|
174
|
-
├── PTYManager (node-pty)
|
|
175
|
+
├── PTYManager (node-pty / ConPTY)
|
|
175
176
|
├── PTYBridge (data forwarding + ActivityMonitor)
|
|
176
177
|
├── AgentDetector (gate-based agent status)
|
|
178
|
+
├── SessionManager (atomic save with .bak recovery)
|
|
179
|
+
├── ScrollbackPersistence (dump/load terminal buffers)
|
|
177
180
|
├── PipeServer (Named Pipe JSON-RPC)
|
|
178
181
|
├── McpRegistrar (auto-registers MCP in ~/.claude.json)
|
|
182
|
+
├── DaemonClient (optional daemon mode connector)
|
|
179
183
|
└── ToastManager (OS notifications + taskbar flash)
|
|
180
184
|
|
|
181
185
|
Renderer Process (React 19 + Zustand)
|
|
182
186
|
├── PaneContainer (recursive split layout)
|
|
183
|
-
├── Terminal (xterm.js + WebGL)
|
|
187
|
+
├── Terminal (xterm.js + WebGL + scrollback restore)
|
|
184
188
|
├── BrowserPanel (webview + Inspector)
|
|
185
189
|
├── NotificationPanel
|
|
186
190
|
└── Multiview grid
|
|
187
191
|
|
|
192
|
+
Daemon Process (optional, standalone)
|
|
193
|
+
├── DaemonSessionManager (ConPTY lifecycle)
|
|
194
|
+
├── RingBuffer (circular scrollback buffer)
|
|
195
|
+
├── StateWriter (session suspend/resume)
|
|
196
|
+
└── DaemonPipeServer (Named Pipe RPC)
|
|
197
|
+
|
|
188
198
|
MCP Server (stdio)
|
|
189
199
|
└── Bridges Claude Code ↔ wmux via Named Pipe RPC
|
|
190
200
|
```
|
|
@@ -4,24 +4,25 @@ exports.handleBrowser = handleBrowser;
|
|
|
4
4
|
const client_1 = require("../client");
|
|
5
5
|
const utils_1 = require("../utils");
|
|
6
6
|
const BROWSER_HELP = `
|
|
7
|
-
wmux browser —
|
|
7
|
+
wmux browser — Browser Commands
|
|
8
8
|
|
|
9
9
|
USAGE
|
|
10
10
|
wmux browser <subcommand> [args]
|
|
11
11
|
|
|
12
12
|
SUBCOMMANDS
|
|
13
|
-
snapshot Return the full page HTML (document.documentElement.outerHTML)
|
|
14
|
-
click <selector> Click the first element matching the CSS selector
|
|
15
|
-
fill <selector> <text> Set the value of an input matching the CSS selector
|
|
16
|
-
eval <code> Execute arbitrary JavaScript in the page context
|
|
17
13
|
navigate <url> Navigate the active browser surface to a URL
|
|
14
|
+
close Close the browser panel
|
|
15
|
+
session start [--profile <name>] Start a browser session
|
|
16
|
+
session stop Stop the active browser session
|
|
17
|
+
session status Show active session status
|
|
18
|
+
session list List available profiles
|
|
18
19
|
|
|
19
20
|
EXAMPLES
|
|
20
|
-
wmux browser snapshot
|
|
21
|
-
wmux browser click "#submit-btn"
|
|
22
|
-
wmux browser fill "input[name=email]" "user@example.com"
|
|
23
|
-
wmux browser eval "document.title"
|
|
24
21
|
wmux browser navigate "https://example.com"
|
|
22
|
+
wmux browser close
|
|
23
|
+
wmux browser session start --profile login
|
|
24
|
+
wmux browser session status
|
|
25
|
+
wmux browser session list
|
|
25
26
|
`.trimStart();
|
|
26
27
|
async function handleBrowser(args, jsonMode) {
|
|
27
28
|
const sub = args[0];
|
|
@@ -32,30 +33,14 @@ async function handleBrowser(args, jsonMode) {
|
|
|
32
33
|
}
|
|
33
34
|
let response;
|
|
34
35
|
switch (sub) {
|
|
35
|
-
// ── browser
|
|
36
|
-
case '
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
(
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
if (!response.ok) {
|
|
43
|
-
(0, utils_1.printError)(response);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const r = response.result;
|
|
47
|
-
process.stdout.write(r?.html ?? '');
|
|
48
|
-
}
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
// ── browser click <selector> ─────────────────────────────────────────────
|
|
52
|
-
case 'click': {
|
|
53
|
-
const selector = rest[0];
|
|
54
|
-
if (!selector) {
|
|
55
|
-
console.error('Error: browser click requires <selector>');
|
|
36
|
+
// ── browser navigate <url> ───────────────────────────────────────────────
|
|
37
|
+
case 'navigate': {
|
|
38
|
+
const url = rest[0];
|
|
39
|
+
if (!url) {
|
|
40
|
+
console.error('Error: browser navigate requires <url>');
|
|
56
41
|
process.exit(1);
|
|
57
42
|
}
|
|
58
|
-
response = await (0, client_1.sendRequest)('browser.
|
|
43
|
+
response = await (0, client_1.sendRequest)('browser.navigate', { url });
|
|
59
44
|
if (jsonMode) {
|
|
60
45
|
(0, utils_1.printResult)(response);
|
|
61
46
|
}
|
|
@@ -64,19 +49,13 @@ async function handleBrowser(args, jsonMode) {
|
|
|
64
49
|
(0, utils_1.printError)(response);
|
|
65
50
|
return;
|
|
66
51
|
}
|
|
67
|
-
console.log(`
|
|
52
|
+
console.log(`Navigated to: ${url}`);
|
|
68
53
|
}
|
|
69
54
|
break;
|
|
70
55
|
}
|
|
71
|
-
// ── browser
|
|
72
|
-
case '
|
|
73
|
-
|
|
74
|
-
const text = rest.slice(1).join(' ');
|
|
75
|
-
if (!selector) {
|
|
76
|
-
console.error('Error: browser fill requires <selector> <text>');
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
response = await (0, client_1.sendRequest)('browser.fill', { selector, text });
|
|
56
|
+
// ── browser close ────────────────────────────────────────────────────────
|
|
57
|
+
case 'close': {
|
|
58
|
+
response = await (0, client_1.sendRequest)('browser.close', {});
|
|
80
59
|
if (jsonMode) {
|
|
81
60
|
(0, utils_1.printResult)(response);
|
|
82
61
|
}
|
|
@@ -85,48 +64,93 @@ async function handleBrowser(args, jsonMode) {
|
|
|
85
64
|
(0, utils_1.printError)(response);
|
|
86
65
|
return;
|
|
87
66
|
}
|
|
88
|
-
console.log(
|
|
67
|
+
console.log('Browser panel closed.');
|
|
89
68
|
}
|
|
90
69
|
break;
|
|
91
70
|
}
|
|
92
|
-
// ── browser
|
|
93
|
-
case '
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
console.
|
|
97
|
-
process.exit(
|
|
98
|
-
}
|
|
99
|
-
response = await (0, client_1.sendRequest)('browser.eval', { code });
|
|
100
|
-
if (jsonMode) {
|
|
101
|
-
(0, utils_1.printResult)(response);
|
|
71
|
+
// ── browser session <action> ─────────────────────────────────────────────
|
|
72
|
+
case 'session': {
|
|
73
|
+
const action = rest[0];
|
|
74
|
+
if (!action || action === '--help' || action === '-h') {
|
|
75
|
+
console.log('Usage: wmux browser session <start|stop|status|list>');
|
|
76
|
+
process.exit(0);
|
|
102
77
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
switch (action) {
|
|
79
|
+
case 'start': {
|
|
80
|
+
const profileIdx = rest.indexOf('--profile');
|
|
81
|
+
const profile = profileIdx !== -1 ? rest[profileIdx + 1] : undefined;
|
|
82
|
+
const params = {};
|
|
83
|
+
if (profile)
|
|
84
|
+
params['profile'] = profile;
|
|
85
|
+
response = await (0, client_1.sendRequest)('browser.session.start', params);
|
|
86
|
+
if (jsonMode) {
|
|
87
|
+
(0, utils_1.printResult)(response);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
(0, utils_1.printError)(response);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const r = response.result;
|
|
95
|
+
console.log(`Session started with profile: ${r['profile'] ?? 'default'}`);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
107
98
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (jsonMode) {
|
|
122
|
-
(0, utils_1.printResult)(response);
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
if (!response.ok) {
|
|
126
|
-
(0, utils_1.printError)(response);
|
|
127
|
-
return;
|
|
99
|
+
case 'stop': {
|
|
100
|
+
response = await (0, client_1.sendRequest)('browser.session.stop', {});
|
|
101
|
+
if (jsonMode) {
|
|
102
|
+
(0, utils_1.printResult)(response);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
(0, utils_1.printError)(response);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log('Session stopped.');
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
128
112
|
}
|
|
129
|
-
|
|
113
|
+
case 'status': {
|
|
114
|
+
response = await (0, client_1.sendRequest)('browser.session.status', {});
|
|
115
|
+
if (jsonMode) {
|
|
116
|
+
(0, utils_1.printResult)(response);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
(0, utils_1.printError)(response);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const r = response.result;
|
|
124
|
+
console.log(`Active profile: ${r['profile'] ?? 'none'}`);
|
|
125
|
+
console.log(`CDP port: ${r['port'] ?? 'none'}`);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'list': {
|
|
130
|
+
response = await (0, client_1.sendRequest)('browser.session.list', {});
|
|
131
|
+
if (jsonMode) {
|
|
132
|
+
(0, utils_1.printResult)(response);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
(0, utils_1.printError)(response);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const profiles = response.result['profiles'];
|
|
140
|
+
if (!profiles || profiles.length === 0) {
|
|
141
|
+
console.log('No profiles found.');
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
for (const p of profiles) {
|
|
145
|
+
console.log(` ${p['name']} (partition: ${p['partition']}, persistent: ${p['persistent']})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
console.error(`Unknown session action: "${action}". Use start, stop, status, or list.`);
|
|
153
|
+
process.exit(1);
|
|
130
154
|
}
|
|
131
155
|
break;
|
|
132
156
|
}
|
package/dist/cli/cli/index.js
CHANGED
|
@@ -51,11 +51,12 @@ SYSTEM COMMANDS
|
|
|
51
51
|
capabilities List all supported RPC methods
|
|
52
52
|
|
|
53
53
|
BROWSER COMMANDS
|
|
54
|
-
browser snapshot Return the full page HTML of the active browser surface
|
|
55
|
-
browser click <selector> Click an element by CSS selector
|
|
56
|
-
browser fill <selector> <text> Fill an input field by CSS selector
|
|
57
|
-
browser eval <code> Execute JavaScript in the browser context
|
|
58
54
|
browser navigate <url> Navigate the browser surface to a URL
|
|
55
|
+
browser close Close the browser panel
|
|
56
|
+
browser session start [--profile <name>] Start a browser session
|
|
57
|
+
browser session stop Stop the active browser session
|
|
58
|
+
browser session status Show active session status
|
|
59
|
+
browser session list List available profiles
|
|
59
60
|
|
|
60
61
|
GLOBAL FLAGS
|
|
61
62
|
--json Output raw JSON (useful for scripting)
|
|
@@ -67,9 +68,8 @@ EXAMPLES
|
|
|
67
68
|
wmux send "echo hello"
|
|
68
69
|
wmux notify --title "Done" --body "Build finished"
|
|
69
70
|
wmux identify --json
|
|
70
|
-
wmux browser snapshot
|
|
71
71
|
wmux browser navigate "https://example.com"
|
|
72
|
-
wmux browser
|
|
72
|
+
wmux browser close
|
|
73
73
|
`.trimStart();
|
|
74
74
|
const WORKSPACE_CMDS = new Set([
|
|
75
75
|
'list-workspaces',
|
|
@@ -45,6 +45,9 @@ exports.IPC = {
|
|
|
45
45
|
FS_WATCH: 'fs:watch',
|
|
46
46
|
FS_UNWATCH: 'fs:unwatch',
|
|
47
47
|
FS_CHANGED: 'fs:changed',
|
|
48
|
+
// Scrollback persistence
|
|
49
|
+
SCROLLBACK_DUMP: 'scrollback:dump',
|
|
50
|
+
SCROLLBACK_LOAD: 'scrollback:load',
|
|
48
51
|
};
|
|
49
52
|
// Named Pipe / Unix socket path for wmux API
|
|
50
53
|
// Fixed name so MCP clients (e.g. Claude Code) can reconnect across wmux restarts
|
package/dist/cli/shared/rpc.js
CHANGED
|
@@ -25,9 +25,20 @@ exports.ALL_RPC_METHODS = [
|
|
|
25
25
|
'system.identify',
|
|
26
26
|
'system.capabilities',
|
|
27
27
|
'browser.open',
|
|
28
|
-
'browser.snapshot',
|
|
29
|
-
'browser.click',
|
|
30
|
-
'browser.fill',
|
|
31
|
-
'browser.eval',
|
|
32
28
|
'browser.navigate',
|
|
29
|
+
'browser.close',
|
|
30
|
+
'browser.session.start',
|
|
31
|
+
'browser.session.stop',
|
|
32
|
+
'browser.session.status',
|
|
33
|
+
'browser.session.list',
|
|
34
|
+
'browser.type.humanlike',
|
|
35
|
+
'browser.cdp.target',
|
|
36
|
+
'browser.cdp.info',
|
|
37
|
+
'daemon.createSession',
|
|
38
|
+
'daemon.destroySession',
|
|
39
|
+
'daemon.attachSession',
|
|
40
|
+
'daemon.detachSession',
|
|
41
|
+
'daemon.resizeSession',
|
|
42
|
+
'daemon.listSessions',
|
|
43
|
+
'daemon.ping',
|
|
33
44
|
];
|
package/dist/mcp/mcp/index.js
CHANGED
|
@@ -5,6 +5,15 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
5
5
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
6
|
const zod_1 = require("zod");
|
|
7
7
|
const wmux_client_1 = require("./wmux-client");
|
|
8
|
+
const PlaywrightEngine_1 = require("./playwright/PlaywrightEngine");
|
|
9
|
+
const navigation_1 = require("./playwright/tools/navigation");
|
|
10
|
+
const interaction_1 = require("./playwright/tools/interaction");
|
|
11
|
+
const inspection_1 = require("./playwright/tools/inspection");
|
|
12
|
+
const state_1 = require("./playwright/tools/state");
|
|
13
|
+
const wait_1 = require("./playwright/tools/wait");
|
|
14
|
+
const file_1 = require("./playwright/tools/file");
|
|
15
|
+
const utility_1 = require("./playwright/tools/utility");
|
|
16
|
+
const extraction_1 = require("./playwright/tools/extraction");
|
|
8
17
|
const server = new mcp_js_1.McpServer({
|
|
9
18
|
name: 'wmux',
|
|
10
19
|
version: '1.0.0',
|
|
@@ -15,30 +24,29 @@ async function callRpc(method, params = {}) {
|
|
|
15
24
|
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
16
25
|
return { content: [{ type: 'text', text }] };
|
|
17
26
|
}
|
|
18
|
-
//
|
|
19
|
-
const optionalSurfaceId = zod_1.z.string().optional().describe('Target a specific surface by ID. Omit to use the active surface.');
|
|
20
|
-
// === Browser tools ===
|
|
27
|
+
// === Browser tools (RPC-based: surface management stays in main process) ===
|
|
21
28
|
server.tool('browser_open', 'Open a new browser panel in the active pane. Use this when no browser surface exists yet.', {
|
|
22
29
|
url: zod_1.z.string().optional().describe('Initial URL to load (defaults to google.com)'),
|
|
23
30
|
}, async ({ url }) => callRpc('browser.open', url ? { url } : {}));
|
|
24
|
-
server.tool('
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
server.tool('browser_close', 'Close the browser panel in the active pane', {
|
|
32
|
+
surfaceId: zod_1.z.string().optional().describe('Target a specific surface by ID. Omit to use the active surface.'),
|
|
33
|
+
}, async ({ surfaceId }) => callRpc('browser.close', surfaceId ? { surfaceId } : {}));
|
|
34
|
+
// === Playwright browser tools ===
|
|
35
|
+
(0, navigation_1.registerNavigationTools)(server);
|
|
36
|
+
(0, interaction_1.registerInteractionTools)(server);
|
|
37
|
+
(0, inspection_1.registerInspectionTools)(server);
|
|
38
|
+
(0, state_1.registerStateTools)(server);
|
|
39
|
+
(0, wait_1.registerWaitTools)(server);
|
|
40
|
+
(0, file_1.registerFileTools)(server);
|
|
41
|
+
(0, utility_1.registerUtilityTools)(server);
|
|
42
|
+
(0, extraction_1.registerExtractionTools)(server);
|
|
43
|
+
// === Browser session tools ===
|
|
44
|
+
server.tool('browser_session_start', 'Start a browser session with the specified profile', {
|
|
45
|
+
profile: zod_1.z.string().optional().describe('Profile name to use (defaults to "default")'),
|
|
46
|
+
}, async ({ profile }) => callRpc('browser.session.start', profile ? { profile } : {}));
|
|
47
|
+
server.tool('browser_session_stop', 'Stop the current browser session', {}, async () => callRpc('browser.session.stop'));
|
|
48
|
+
server.tool('browser_session_status', 'Get current browser session status', {}, async () => callRpc('browser.session.status'));
|
|
49
|
+
server.tool('browser_session_list', 'List available browser profiles', {}, async () => callRpc('browser.session.list'));
|
|
42
50
|
// === Terminal tools ===
|
|
43
51
|
server.tool('terminal_read', 'Read the current visible text from the active terminal in wmux', {}, async () => callRpc('input.readScreen'));
|
|
44
52
|
server.tool('terminal_send', 'Send text to the active terminal in wmux', { text: zod_1.z.string().describe('Text to send to the terminal') }, async ({ text }) => callRpc('input.send', { text }));
|
|
@@ -53,6 +61,18 @@ server.tool('pane_list', 'List all panes in the current workspace', {}, async ()
|
|
|
53
61
|
async function main() {
|
|
54
62
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
55
63
|
await server.connect(transport);
|
|
64
|
+
// Clean up Playwright connection when transport closes
|
|
65
|
+
transport.onclose = async () => {
|
|
66
|
+
console.log('[wmux-mcp] Transport closed, disconnecting Playwright');
|
|
67
|
+
await PlaywrightEngine_1.PlaywrightEngine.getInstance().disconnect();
|
|
68
|
+
};
|
|
69
|
+
// Graceful shutdown
|
|
70
|
+
const shutdown = async () => {
|
|
71
|
+
await PlaywrightEngine_1.PlaywrightEngine.getInstance().disconnect();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
};
|
|
74
|
+
process.on('SIGTERM', shutdown);
|
|
75
|
+
process.on('SIGINT', shutdown);
|
|
56
76
|
}
|
|
57
77
|
main().catch((err) => {
|
|
58
78
|
console.error('wmux MCP server failed to start:', err);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PlaywrightEngine = void 0;
|
|
4
|
+
const playwright_core_1 = require("playwright-core");
|
|
5
|
+
const wmux_client_1 = require("../wmux-client");
|
|
6
|
+
const MAX_CONNECT_RETRIES = 3;
|
|
7
|
+
const RETRY_DELAY_MS = 1000;
|
|
8
|
+
const PAGE_FIND_RETRIES = 5;
|
|
9
|
+
const PAGE_FIND_DELAY_MS = 800;
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns true if the URL belongs to the Electron main renderer window.
|
|
15
|
+
* Navigating these pages would destroy the app — they must never be returned.
|
|
16
|
+
*/
|
|
17
|
+
function isElectronShellUrl(url) {
|
|
18
|
+
return (url.startsWith('http://localhost:') ||
|
|
19
|
+
url.startsWith('devtools://') ||
|
|
20
|
+
url.startsWith('chrome://'));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* PlaywrightEngine -- singleton wrapper around playwright-core's Chromium CDP connection.
|
|
24
|
+
*
|
|
25
|
+
* Connects to the wmux Electron app via Chrome DevTools Protocol and provides
|
|
26
|
+
* access to browser pages for automation.
|
|
27
|
+
*/
|
|
28
|
+
class PlaywrightEngine {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.browser = null;
|
|
31
|
+
this.cdpPort = null;
|
|
32
|
+
}
|
|
33
|
+
static getInstance() {
|
|
34
|
+
if (!PlaywrightEngine.instance) {
|
|
35
|
+
PlaywrightEngine.instance = new PlaywrightEngine();
|
|
36
|
+
}
|
|
37
|
+
return PlaywrightEngine.instance;
|
|
38
|
+
}
|
|
39
|
+
async connect(cdpPort) {
|
|
40
|
+
if (this.browser && this.cdpPort === cdpPort && this.browser.isConnected()) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await this.disconnect();
|
|
44
|
+
this.browser = await playwright_core_1.chromium.connectOverCDP(`http://localhost:${cdpPort}`);
|
|
45
|
+
this.cdpPort = cdpPort;
|
|
46
|
+
console.log(`[PlaywrightEngine] Connected to CDP on port ${cdpPort}`);
|
|
47
|
+
}
|
|
48
|
+
async disconnect() {
|
|
49
|
+
if (this.browser) {
|
|
50
|
+
this.browser = null;
|
|
51
|
+
this.cdpPort = null;
|
|
52
|
+
console.log('[PlaywrightEngine] Disconnected');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Force reconnect — drops existing connection and creates a fresh one.
|
|
57
|
+
* Needed when new webviews are created after the initial connection,
|
|
58
|
+
* because connectOverCDP only discovers targets at connection time.
|
|
59
|
+
*/
|
|
60
|
+
async reconnect() {
|
|
61
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
62
|
+
await this.disconnect();
|
|
63
|
+
await this.connect(info.cdpPort);
|
|
64
|
+
}
|
|
65
|
+
async ensureConnected() {
|
|
66
|
+
if (this.browser?.isConnected())
|
|
67
|
+
return;
|
|
68
|
+
for (let attempt = 1; attempt <= MAX_CONNECT_RETRIES; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
71
|
+
await this.connect(info.cdpPort);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.warn(`[PlaywrightEngine] Connection attempt ${attempt}/${MAX_CONNECT_RETRIES} failed:`, err instanceof Error ? err.message : String(err));
|
|
76
|
+
if (attempt < MAX_CONNECT_RETRIES) {
|
|
77
|
+
await sleep(RETRY_DELAY_MS);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`[PlaywrightEngine] Failed to connect after ${MAX_CONNECT_RETRIES} attempts`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Collect all Playwright Page objects from all contexts.
|
|
85
|
+
*/
|
|
86
|
+
getAllPages() {
|
|
87
|
+
if (!this.browser || !this.browser.isConnected())
|
|
88
|
+
return [];
|
|
89
|
+
const pages = [];
|
|
90
|
+
for (const ctx of this.browser.contexts()) {
|
|
91
|
+
pages.push(...ctx.pages());
|
|
92
|
+
}
|
|
93
|
+
return pages;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Fetch the CDP /json target list.
|
|
97
|
+
*/
|
|
98
|
+
async fetchJsonTargets() {
|
|
99
|
+
if (!this.cdpPort)
|
|
100
|
+
return [];
|
|
101
|
+
const resp = await fetch(`http://127.0.0.1:${this.cdpPort}/json`);
|
|
102
|
+
return (await resp.json());
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Try to find a Playwright Page that corresponds to a registered webview target.
|
|
106
|
+
* Returns null if no safe page can be found.
|
|
107
|
+
*/
|
|
108
|
+
async findWebviewPage(allPages, target) {
|
|
109
|
+
// Strategy 1: Match by targetId → URL from /json endpoint
|
|
110
|
+
if (target) {
|
|
111
|
+
try {
|
|
112
|
+
const jsonTargets = await this.fetchJsonTargets();
|
|
113
|
+
const jsonTarget = jsonTargets.find((t) => t.id === target.targetId);
|
|
114
|
+
if (jsonTarget && !isElectronShellUrl(jsonTarget.url)) {
|
|
115
|
+
// Find Playwright page with matching URL
|
|
116
|
+
const matched = allPages.find((p) => p.url() === jsonTarget.url);
|
|
117
|
+
if (matched)
|
|
118
|
+
return matched;
|
|
119
|
+
// URL might differ slightly (trailing slash, redirect) — try loose match
|
|
120
|
+
const normalizedTarget = jsonTarget.url.replace(/\/+$/, '');
|
|
121
|
+
const looseMatch = allPages.find((p) => p.url().replace(/\/+$/, '') === normalizedTarget);
|
|
122
|
+
if (looseMatch)
|
|
123
|
+
return looseMatch;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// /json fetch failed
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Strategy 2: Any page that isn't the Electron shell
|
|
131
|
+
// about:blank is allowed — webviews start there before navigating
|
|
132
|
+
const candidates = allPages.filter((p) => !isElectronShellUrl(p.url()));
|
|
133
|
+
if (candidates.length > 0) {
|
|
134
|
+
return candidates[0];
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get a Page matching the given surfaceId.
|
|
140
|
+
*
|
|
141
|
+
* Includes retry logic: if no webview page is found on the first attempt,
|
|
142
|
+
* reconnects to CDP (to discover newly created webview targets) and retries.
|
|
143
|
+
*/
|
|
144
|
+
async getPage(surfaceId) {
|
|
145
|
+
await this.ensureConnected();
|
|
146
|
+
for (let attempt = 1; attempt <= PAGE_FIND_RETRIES; attempt++) {
|
|
147
|
+
const allPages = this.getAllPages();
|
|
148
|
+
if (allPages.length === 0 && attempt < PAGE_FIND_RETRIES) {
|
|
149
|
+
// No pages yet — reconnect to discover new targets
|
|
150
|
+
await sleep(PAGE_FIND_DELAY_MS);
|
|
151
|
+
await this.reconnect();
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Get registered webview targets
|
|
155
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
156
|
+
const target = surfaceId
|
|
157
|
+
? info.targets.find((t) => t.surfaceId === surfaceId)
|
|
158
|
+
: info.targets[0];
|
|
159
|
+
// If no targets registered yet, wait for webview to initialize
|
|
160
|
+
if (!target && attempt < PAGE_FIND_RETRIES) {
|
|
161
|
+
console.log(`[PlaywrightEngine] No CDP targets registered yet, retry ${attempt}/${PAGE_FIND_RETRIES}...`);
|
|
162
|
+
await sleep(PAGE_FIND_DELAY_MS);
|
|
163
|
+
// Reconnect to pick up newly created webview targets
|
|
164
|
+
await this.reconnect();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const page = await this.findWebviewPage(allPages, target);
|
|
168
|
+
if (page)
|
|
169
|
+
return page;
|
|
170
|
+
// Page not found — reconnect and retry (new webview might not be visible yet)
|
|
171
|
+
if (attempt < PAGE_FIND_RETRIES) {
|
|
172
|
+
console.log(`[PlaywrightEngine] Webview page not found, reconnecting... (${attempt}/${PAGE_FIND_RETRIES})`);
|
|
173
|
+
await sleep(PAGE_FIND_DELAY_MS);
|
|
174
|
+
await this.reconnect();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
console.warn('[PlaywrightEngine] No webview page found after all retries');
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
async getBrowser() {
|
|
181
|
+
await this.ensureConnected();
|
|
182
|
+
return this.browser;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.PlaywrightEngine = PlaywrightEngine;
|
|
186
|
+
PlaywrightEngine.instance = null;
|