@bubstack/moe-glass 0.1.0

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 (62) hide show
  1. package/README.md +29 -0
  2. package/agents/browser-user.md +105 -0
  3. package/dist/LICENSE +25 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22517 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/payload.d.ts +214 -0
  9. package/dist/payload.d.ts.map +1 -0
  10. package/dist/payload.js +325 -0
  11. package/dist/payload.js.map +1 -0
  12. package/package.json +59 -0
  13. package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
  14. package/skills/browsing/EXAMPLES.md +717 -0
  15. package/skills/browsing/README.md +55 -0
  16. package/skills/browsing/SKILL.md +478 -0
  17. package/skills/browsing/chrome-ws +1021 -0
  18. package/skills/browsing/chrome-ws-lib.js +461 -0
  19. package/skills/browsing/host-override.js +98 -0
  20. package/skills/browsing/lib/browser-bridge.js +175 -0
  21. package/skills/browsing/lib/browser-session.js +137 -0
  22. package/skills/browsing/lib/capture.js +499 -0
  23. package/skills/browsing/lib/cdp-router.js +72 -0
  24. package/skills/browsing/lib/cdp-utils.js +18 -0
  25. package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
  26. package/skills/browsing/lib/chrome-process.js +464 -0
  27. package/skills/browsing/lib/console-logging.js +70 -0
  28. package/skills/browsing/lib/cookies.js +17 -0
  29. package/skills/browsing/lib/dialogs-render.js +154 -0
  30. package/skills/browsing/lib/dialogs-router.js +117 -0
  31. package/skills/browsing/lib/dialogs.js +254 -0
  32. package/skills/browsing/lib/element-selector.js +91 -0
  33. package/skills/browsing/lib/evaluation.js +85 -0
  34. package/skills/browsing/lib/extraction.js +55 -0
  35. package/skills/browsing/lib/file-upload.js +56 -0
  36. package/skills/browsing/lib/html-diff.js +122 -0
  37. package/skills/browsing/lib/key-definitions.js +149 -0
  38. package/skills/browsing/lib/keyboard-input.js +288 -0
  39. package/skills/browsing/lib/mouse.js +423 -0
  40. package/skills/browsing/lib/navigation.js +272 -0
  41. package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
  42. package/skills/browsing/lib/page-scripts/markdown.js +85 -0
  43. package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
  44. package/skills/browsing/lib/page-session.js +106 -0
  45. package/skills/browsing/lib/profile-lock.js +179 -0
  46. package/skills/browsing/lib/screenshot.js +171 -0
  47. package/skills/browsing/lib/select-option.js +99 -0
  48. package/skills/browsing/lib/session-state.js +66 -0
  49. package/skills/browsing/lib/tabs.js +144 -0
  50. package/skills/browsing/lib/viewport.js +103 -0
  51. package/skills/browsing/lib/websocket-client.js +162 -0
  52. package/skills/browsing/package.json +11 -0
  53. package/skills/browsing/test-chrome-args.js +81 -0
  54. package/skills/browsing/test-cookies.js +21 -0
  55. package/skills/browsing/test-e2e.sh +51 -0
  56. package/skills/browsing/test-extract.sh +17 -0
  57. package/skills/browsing/test-interact.sh +11 -0
  58. package/skills/browsing/test-navigate.sh +9 -0
  59. package/skills/browsing/test-raw.sh +8 -0
  60. package/skills/browsing/test-tabs.sh +15 -0
  61. package/skills/browsing/test-viewport.js +27 -0
  62. package/skills/browsing/test-wait.sh +9 -0
@@ -0,0 +1,31 @@
1
+ // Page-side script: token-efficient page summary used by auto-capture.
2
+ // Loaded as a string at attachCapture setup and embedded in CDP
3
+ // Runtime.evaluate. Tested directly against jsdom in
4
+ // test/lib/page-scripts/dom-summary.test.mjs.
5
+ module.exports = `
6
+ (() => {
7
+ const buttons = document.querySelectorAll('button, input[type="button"], input[type="submit"]').length;
8
+ const inputs = document.querySelectorAll('input:not([type="button"]):not([type="submit"]), textarea, select').length;
9
+ const links = document.querySelectorAll('a[href]').length;
10
+
11
+ const title = document.title.slice(0, 60);
12
+ const allH1s = Array.from(document.querySelectorAll('h1')).map(h => h.textContent.trim().slice(0, 40)).filter(Boolean);
13
+ const h1s = allH1s.slice(0, 3);
14
+ const h1Extra = allH1s.length > 3 ? allH1s.length - 3 : 0;
15
+
16
+ const main = document.querySelector('main, [role="main"], .main, #main, .content, #content');
17
+ const mainTag = main ? main.tagName.toLowerCase() + (main.id ? '#' + main.id : main.className ? '.' + main.className.split(' ')[0] : '') : 'body';
18
+
19
+ const forms = document.querySelectorAll('form');
20
+ const formInfo = forms.length > 0 ? \`\${forms.length} form\${forms.length > 1 ? 's' : ''}\` : '';
21
+
22
+ const nav = document.querySelector('nav, [role="navigation"], .nav, #nav') ? 'nav' : '';
23
+
24
+ return [
25
+ \`\${title}\`,
26
+ \`Interactive: \${buttons} buttons, \${inputs} inputs, \${links} links\`,
27
+ h1s.length > 0 ? \`Headings: \${h1s.map(h => '"' + h + '"').join(', ')}\${h1Extra > 0 ? ', and ' + h1Extra + ' more' : ''}\` : '',
28
+ \`Layout: \${nav ? 'nav + ' : ''}\${mainTag}\${formInfo ? ' + ' + formInfo : ''}\`
29
+ ].filter(Boolean).join('\\n');
30
+ })()
31
+ `;
@@ -0,0 +1,85 @@
1
+ // Page-side script: walk the DOM and emit token-efficient Markdown.
2
+ // Loaded as a string at attachCapture setup and embedded in CDP
3
+ // Runtime.evaluate. Tested directly against jsdom in
4
+ // test/lib/page-scripts/markdown.test.mjs.
5
+ //
6
+ // Includes images >= 100x100 in a header summary; inlines image references
7
+ // >= 50x50 with size info; skips smaller icons.
8
+ module.exports = `
9
+ (() => {
10
+ const results = [];
11
+
12
+ const title = document.title;
13
+ if (title) results.push(\`# \${title}\\n\`);
14
+
15
+ const allImages = document.querySelectorAll('img');
16
+ const significantImages = Array.from(allImages).filter(img => {
17
+ const rect = img.getBoundingClientRect();
18
+ return rect.width >= 100 && rect.height >= 100;
19
+ });
20
+
21
+ if (significantImages.length > 0) {
22
+ results.push(\`\\n**📷 This page contains \${significantImages.length} significant image(s). Check screenshot.png for visual content.**\\n\`);
23
+ }
24
+
25
+ const elements = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, a, li, pre, code, blockquote, table, img, figure');
26
+
27
+ for (const el of elements) {
28
+ const tag = el.tagName.toLowerCase();
29
+ const text = el.textContent.trim();
30
+
31
+ if (tag === 'img') {
32
+ const alt = el.alt || '';
33
+ const src = el.src || '';
34
+ const rect = el.getBoundingClientRect();
35
+ if (rect.width >= 50 && rect.height >= 50) {
36
+ const sizeInfo = \`\${Math.round(rect.width)}x\${Math.round(rect.height)}\`;
37
+ const description = alt ? \`"\${alt}"\` : '(no alt text)';
38
+ results.push(\`\\n![Image: \${description} - \${sizeInfo}](\${src})\\n\`);
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (tag === 'figure') {
44
+ const figcaption = el.querySelector('figcaption');
45
+ if (figcaption) {
46
+ results.push(\`\\n*Figure: \${figcaption.textContent.trim()}*\\n\`);
47
+ }
48
+ continue;
49
+ }
50
+
51
+ if (!text) continue;
52
+
53
+ if (tag.startsWith('h')) {
54
+ const level = parseInt(tag[1]);
55
+ results.push(\`\${'#'.repeat(level)} \${text}\\n\`);
56
+ } else if (tag === 'p') {
57
+ results.push(\`\${text}\\n\`);
58
+ } else if (tag === 'a') {
59
+ const href = el.href;
60
+ results.push(\`[\${text}](\${href})\`);
61
+ } else if (tag === 'li') {
62
+ results.push(\`- \${text}\`);
63
+ } else if (tag === 'pre' || tag === 'code') {
64
+ results.push(\`\\\`\\\`\\\`\\n\${text}\\n\\\`\\\`\\\`\\n\`);
65
+ } else if (tag === 'blockquote') {
66
+ results.push(\`> \${text}\\n\`);
67
+ } else if (tag === 'table') {
68
+ const rows = el.querySelectorAll('tr');
69
+ if (rows.length > 0) {
70
+ results.push('\\n| Table Content |\\n|---|');
71
+ for (let i = 0; i < Math.min(rows.length, 10); i++) {
72
+ const cells = rows[i].querySelectorAll('td, th');
73
+ const cellTexts = Array.from(cells).map(cell => cell.textContent.trim()).slice(0, 3);
74
+ if (cellTexts.length > 0) {
75
+ results.push(\`| \${cellTexts.join(' | ')} |\`);
76
+ }
77
+ }
78
+ results.push('\\n');
79
+ }
80
+ }
81
+ }
82
+
83
+ return results.join('\\n').slice(0, 50000); // Limit size
84
+ })()
85
+ `;
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Source of the shim that runs in every page at document_start.
4
+ // Exported as a string; `dialogs.attachToConnection` registers it via
5
+ // `Page.addScriptToEvaluateOnNewDocument`.
6
+
7
+ const SHIM_SOURCE = `
8
+ (() => {
9
+ const BINDING = '__dialogShim';
10
+ const pending = new Map();
11
+ let nextId = 1;
12
+
13
+ function ask(name, jsApi) {
14
+ const id = String(nextId++);
15
+ return new Promise((resolve) => {
16
+ pending.set(id, resolve);
17
+ window[BINDING](JSON.stringify({ type: 'permission-request', id, name, jsApi, origin: location.origin }));
18
+ });
19
+ }
20
+
21
+ window[BINDING + '_resolve'] = (id, resolution) => {
22
+ const r = pending.get(id);
23
+ if (r) { pending.delete(id); r(resolution); }
24
+ };
25
+
26
+ // getUserMedia
27
+ if (navigator.mediaDevices) {
28
+ const origGetUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
29
+ navigator.mediaDevices.getUserMedia = async function(constraints) {
30
+ const name = constraints && constraints.video ? 'camera' : 'microphone';
31
+ const decision = await ask(name, 'navigator.mediaDevices.getUserMedia');
32
+ if (decision === 'grant') return origGetUM(constraints);
33
+ throw new DOMException('Permission denied', 'NotAllowedError');
34
+ };
35
+ }
36
+
37
+ // Notification.requestPermission
38
+ if (typeof Notification !== 'undefined') {
39
+ const orig = Notification.requestPermission.bind(Notification);
40
+ Notification.requestPermission = async function(cb) {
41
+ const decision = await ask('notifications', 'Notification.requestPermission');
42
+ const result = decision === 'grant' ? 'granted' : 'denied';
43
+ if (typeof cb === 'function') cb(result);
44
+ return result;
45
+ };
46
+ }
47
+
48
+ // Geolocation
49
+ if (navigator.geolocation) {
50
+ const origGet = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
51
+ navigator.geolocation.getCurrentPosition = async function(success, error, opts) {
52
+ const decision = await ask('geolocation', 'navigator.geolocation.getCurrentPosition');
53
+ if (decision === 'grant') return origGet(success, error, opts);
54
+ if (error) error(new DOMException('Permission denied', 'NotAllowedError'));
55
+ };
56
+ }
57
+
58
+ // Clipboard
59
+ if (navigator.clipboard) {
60
+ if (navigator.clipboard.readText) {
61
+ const orig = navigator.clipboard.readText.bind(navigator.clipboard);
62
+ navigator.clipboard.readText = async function() {
63
+ const decision = await ask('clipboard-read', 'navigator.clipboard.readText');
64
+ if (decision === 'grant') return orig();
65
+ throw new DOMException('Permission denied', 'NotAllowedError');
66
+ };
67
+ }
68
+ if (navigator.clipboard.writeText) {
69
+ const orig = navigator.clipboard.writeText.bind(navigator.clipboard);
70
+ navigator.clipboard.writeText = async function(text) {
71
+ const decision = await ask('clipboard-write', 'navigator.clipboard.writeText');
72
+ if (decision === 'grant') return orig(text);
73
+ throw new DOMException('Permission denied', 'NotAllowedError');
74
+ };
75
+ }
76
+ }
77
+ })();
78
+ `;
79
+
80
+ module.exports = { SHIM_SOURCE };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Per-page CDP session over the browser-WS, attached via Target.attachToTarget({flatten:true}).
5
+ *
6
+ * Each pageSession wraps:
7
+ * - sessionId (from Target.attachToTarget)
8
+ * - targetId (underlying CDP target)
9
+ * - a per-session message-id counter (independent of other sessions — collapsing
10
+ * id space across sessions would silently misroute responses)
11
+ * - pendingRequests + eventListeners (held in the cdp-router)
12
+ *
13
+ * pageSession.send is the only way page-action commands reach Chrome via this transport.
14
+ * There is no fallback. If the browser-WS dies, the call rejects and the caller decides
15
+ * what to do.
16
+ */
17
+
18
+ /**
19
+ * buildPageSessionFromAttached — constructs a pageSession from an already-attached
20
+ * sessionId (e.g. from Target.attachedToTarget autoAttach) without making any CDP
21
+ * calls. The caller is responsible for registering the session with the router
22
+ * BEFORE calling this function — or passing the sess object from registerSession.
23
+ *
24
+ * Both attachPageSession and the auto-attach handler in browser-bridge use this.
25
+ */
26
+ function buildPageSessionFromAttached({ browser, router, sessionId, targetId }) {
27
+ const sess = router.registerSession(sessionId);
28
+ let messageIdCounter = 1;
29
+ let detached = false;
30
+ const enabledDomains = new Set();
31
+
32
+ async function send(method, params = {}, { timeoutMs = 30000 } = {}) {
33
+ if (detached) throw new Error(`Page session detached (sessionId=${sessionId})`);
34
+ const id = messageIdCounter++;
35
+ return new Promise((resolve, reject) => {
36
+ const timeout = setTimeout(() => {
37
+ sess.pendingRequests.delete(id);
38
+ reject(new Error(`Page session timeout: ${method}`));
39
+ }, timeoutMs);
40
+ sess.pendingRequests.set(id, { resolve, reject, timeout });
41
+ // browser.send doesn't natively envelope by sessionId, so we use the sendRaw
42
+ // escape hatch with a pre-built JSON payload. The cdp-router correlates the
43
+ // response by sessionId.
44
+ try {
45
+ browser.sendRaw(JSON.stringify({ id, method, params, sessionId }));
46
+ } catch (e) {
47
+ clearTimeout(timeout);
48
+ sess.pendingRequests.delete(id);
49
+ reject(e);
50
+ }
51
+ });
52
+ }
53
+
54
+ function onEvent(handler) {
55
+ sess.eventListeners.add(handler);
56
+ return () => sess.eventListeners.delete(handler);
57
+ }
58
+
59
+ function waitForEvent(method, { timeoutMs = 15000 } = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ let unsub = null;
62
+ const timeout = setTimeout(() => {
63
+ if (unsub) unsub();
64
+ reject(new Error(`waitForEvent ${method}: timed out after ${timeoutMs}ms`));
65
+ }, timeoutMs);
66
+ unsub = onEvent((msg) => {
67
+ if (msg.method === method) {
68
+ clearTimeout(timeout);
69
+ unsub();
70
+ resolve(msg);
71
+ }
72
+ });
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Enable a CDP domain idempotently. Multiple callers (navigation auto-capture +
78
+ * console-logging stream, etc.) can call enableDomain('Runtime') without
79
+ * coordinating — it's a no-op if already enabled.
80
+ */
81
+ async function enableDomain(name) {
82
+ if (enabledDomains.has(name)) return;
83
+ await send(`${name}.enable`, {});
84
+ enabledDomains.add(name);
85
+ }
86
+
87
+ async function detach() {
88
+ if (detached) return;
89
+ detached = true;
90
+ try {
91
+ await browser.send('Target.detachFromTarget', { sessionId });
92
+ } catch {
93
+ // best-effort — Chrome may already have torn down the target
94
+ }
95
+ router.unregisterSession(sessionId);
96
+ }
97
+
98
+ return { sessionId, targetId, send, onEvent, waitForEvent, enableDomain, detach };
99
+ }
100
+
101
+ async function attachPageSession({ browser, router }, targetId) {
102
+ const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
103
+ return buildPageSessionFromAttached({ browser, router, sessionId, targetId });
104
+ }
105
+
106
+ module.exports = { attachPageSession, buildPageSessionFromAttached };
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { getChromeProfileDir } = require('./chrome-launcher-helpers');
7
+
8
+ /**
9
+ * Per-profile MCP-instance lock.
10
+ *
11
+ * The bridge defaults every MCP server to the `moe-glass` profile,
12
+ * which means two MCP processes running on the same host silently end up
13
+ * driving the same Chrome (the second one reconnects to the first's instance
14
+ * via the meta.json path). Their `activeTab` pointers stomp on each other and
15
+ * the agents fight over tabs without any error surfacing.
16
+ *
17
+ * The fix is to claim a per-process lock file next to the profile dir and,
18
+ * on conflict with a live holder, auto-pick an unused alternate name
19
+ * (`moe-glass-2`, `-3`, etc.). The first MCP keeps the simple
20
+ * default; later MCPs get their own Chrome silently.
21
+ *
22
+ * Layout:
23
+ * ~/.cache/moe/browser-profiles/<profile>/ — Chrome user-data-dir
24
+ * ~/.cache/moe/browser-profiles/<profile>.meta.json — port/pid (Chrome process)
25
+ * ~/.cache/moe/browser-profiles/<profile>.mcp.lock — MCP-instance lock
26
+ *
27
+ * Semantics:
28
+ * - `acquireWithFallback(base)` is the entry point used by chrome-process.js.
29
+ * It tries `base`, then `base-2`, `base-3`, ... until it claims one or
30
+ * runs out of slots. Returns { profileName, lockPath }.
31
+ * - `acquire(profileName)` is a single-profile try-lock. Returns the
32
+ * lock path on success, `null` on live-holder conflict, and throws on
33
+ * unexpected I/O errors.
34
+ * - `release(lockPath)` removes the lock; safe to call unconditionally.
35
+ * - A lock whose pid is no longer alive is treated as stale and overwritten
36
+ * atomically.
37
+ * - File creation uses `wx` flag for atomicity — two MCPs starting at the
38
+ * same millisecond can't both win.
39
+ *
40
+ * Lock file shape:
41
+ * { pid: number, mcpPid: number, startedAt: ISO8601, version: number }
42
+ * `version` is the on-disk format; bump if the shape ever changes.
43
+ */
44
+
45
+ const LOCK_FORMAT_VERSION = 1;
46
+ const MAX_PROFILE_SLOTS = 100;
47
+
48
+ function getProfileLockPath(profileName) {
49
+ // Sibling of the profile dir, mirroring the meta.json placement.
50
+ // Uses the same directory the launcher already created for the profile.
51
+ return path.join(path.dirname(getChromeProfileDir(profileName)), `${profileName}.mcp.lock`);
52
+ }
53
+
54
+ function isPidAlive(pid) {
55
+ if (!pid || !Number.isFinite(pid)) return false;
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch (e) {
60
+ // ESRCH = no such process. EPERM = process exists but we can't signal it
61
+ // (usually a different user) — still treat as alive so we don't stomp.
62
+ return e && e.code === 'EPERM';
63
+ }
64
+ }
65
+
66
+ function readLockFile(lockPath) {
67
+ try {
68
+ const raw = fs.readFileSync(lockPath, 'utf8');
69
+ return JSON.parse(raw);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function writeLockFile(lockPath, { atomic = true } = {}) {
76
+ const payload = {
77
+ pid: process.pid,
78
+ mcpPid: process.pid,
79
+ startedAt: new Date().toISOString(),
80
+ version: LOCK_FORMAT_VERSION,
81
+ };
82
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
83
+ // `wx` opens for write but fails if the file exists — atomic claim.
84
+ // The non-atomic variant is used only after we've verified the prior
85
+ // holder is dead and unlinked their lock.
86
+ const flag = atomic ? 'wx' : 'w';
87
+ fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2), { flag });
88
+ }
89
+
90
+ function tryAtomicClaim(lockPath) {
91
+ try {
92
+ writeLockFile(lockPath);
93
+ return true;
94
+ } catch (e) {
95
+ if (e && e.code === 'EEXIST') return false;
96
+ throw e;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Try to acquire the lock for one specific profile.
102
+ *
103
+ * Returns the absolute lock path on success.
104
+ * Returns `null` if another live MCP already holds it.
105
+ */
106
+ function acquire(profileName) {
107
+ const lockPath = getProfileLockPath(profileName);
108
+
109
+ // First pass: try atomic create. Wins if no lock exists.
110
+ if (tryAtomicClaim(lockPath)) return lockPath;
111
+
112
+ // Lock file already exists — inspect it.
113
+ const existing = readLockFile(lockPath);
114
+ if (existing && isPidAlive(existing.pid)) {
115
+ return null; // Another MCP is live on this profile.
116
+ }
117
+
118
+ // Stale (dead pid or unreadable). Remove and retake. The unlink → write
119
+ // pair is not atomic; a parallel acquirer could squeeze in between them.
120
+ // Re-test atomically after the rewrite to detect that race.
121
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
122
+ if (tryAtomicClaim(lockPath)) return lockPath;
123
+
124
+ // Someone else took it between our unlink and our claim. Re-inspect.
125
+ const racer = readLockFile(lockPath);
126
+ if (racer && isPidAlive(racer.pid)) return null;
127
+ // Their pid is also dead? Weird. Bail rather than loop forever.
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Acquire a lock for `baseProfileName`. If another live MCP holds it,
133
+ * fall through to `<base>-2`, `<base>-3`, ... up to MAX_PROFILE_SLOTS.
134
+ *
135
+ * Returns `{ profileName, lockPath, slot }` on success, where `slot` is 1
136
+ * for the base name and N for the (N-1)-suffixed alternate.
137
+ *
138
+ * Throws if no slot is available — practically only happens if 100 MCPs
139
+ * are all live on the same host, which means something is wrong.
140
+ */
141
+ function acquireWithFallback(baseProfileName) {
142
+ for (let slot = 1; slot <= MAX_PROFILE_SLOTS; slot++) {
143
+ const candidate = slot === 1 ? baseProfileName : `${baseProfileName}-${slot}`;
144
+ const lockPath = acquire(candidate);
145
+ if (lockPath) {
146
+ return { profileName: candidate, lockPath, slot };
147
+ }
148
+ }
149
+ throw new Error(
150
+ `Could not acquire a profile lock — ${MAX_PROFILE_SLOTS} live MCP instances ` +
151
+ `for base '${baseProfileName}'? Use CHROME_WS_PROFILE to set a unique name.`
152
+ );
153
+ }
154
+
155
+ function release(lockPath) {
156
+ if (!lockPath) return;
157
+ try {
158
+ // Only remove if it's still ours. Two cases where it isn't:
159
+ // 1. Some other process unlinked + recreated it (lock file PID differs)
160
+ // 2. The MCP that holds it has been replaced — same pid would be a coincidence
161
+ // We compare pid before unlinking to avoid removing a successor's lock.
162
+ const existing = readLockFile(lockPath);
163
+ if (existing && existing.pid !== process.pid) return;
164
+ fs.unlinkSync(lockPath);
165
+ } catch {
166
+ // Already gone or unwritable — nothing to do.
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ acquire,
172
+ acquireWithFallback,
173
+ release,
174
+ // Exposed for tests:
175
+ getProfileLockPath,
176
+ isPidAlive,
177
+ readLockFile,
178
+ LOCK_FORMAT_VERSION,
179
+ };
@@ -0,0 +1,171 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execFileSync } = require('child_process');
4
+ const os = require('os');
5
+ const { getElementSelector } = require('./element-selector');
6
+ const { throwIfExceptionDetails } = require('./cdp-utils');
7
+
8
+ // Auto-downscale cap so screenshots fit Claude's many-image mode size limit
9
+ // (max 2000px). Headroom of 200px keeps us safely under.
10
+ const MAX_IMAGE_DIMENSION_PX = 1800;
11
+
12
+ /**
13
+ * Page / element / full-page screenshots via CDP Page.captureScreenshot,
14
+ * with auto-downscaling so the resulting PNG fits Claude's many-image mode
15
+ * size limit (max dimension 2000 — we cap at 1800 for headroom).
16
+ *
17
+ * Three clip modes, picked from the args:
18
+ * - `fullPage: true` — Page.getLayoutMetrics → captureBeyondViewport
19
+ * - `selector` set — element's getBoundingClientRect
20
+ * - default — explicit viewport clip from window.innerWidth/Height
21
+ *
22
+ * The default-viewport clip is load-bearing on Linux: without it Chrome
23
+ * uses its internal DPI-scaled dimensions, which produces oversized
24
+ * screenshots on HiDPI displays (Xft.dpi:144 etc).
25
+ *
26
+ * Downscaling is best-effort and platform-specific (sips on macOS,
27
+ * ImageMagick on Linux, no-op on Windows). Failures are silent — better
28
+ * to have a big PNG than no PNG.
29
+ *
30
+ * Path resolution for user-supplied filenames:
31
+ * - Absolute path (starts with `/` or a Windows drive letter) → used as-is.
32
+ * - Relative path → resolved against the session directory. If no session
33
+ * directory exists yet, `initializeSession()` is called to create one.
34
+ * - No filename supplied → auto-generates a timestamped name in session dir.
35
+ *
36
+ * `attachScreenshot({ getPageSession, state, initializeSession })` returns
37
+ * the bound action. `state` and `initializeSession` are optional; when
38
+ * absent, relative paths are resolved against CWD (legacy behaviour).
39
+ */
40
+ function attachScreenshot({ getPageSession, state, initializeSession }) {
41
+ async function downscaleImageIfNeeded(filepath, maxDimension = MAX_IMAGE_DIMENSION_PX) {
42
+ const platform = os.platform();
43
+
44
+ try {
45
+ let width, height;
46
+
47
+ if (platform === 'darwin') {
48
+ const output = execFileSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', filepath], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
49
+ const widthMatch = output.match(/pixelWidth:\s*(\d+)/);
50
+ const heightMatch = output.match(/pixelHeight:\s*(\d+)/);
51
+ width = widthMatch ? parseInt(widthMatch[1]) : 0;
52
+ height = heightMatch ? parseInt(heightMatch[1]) : 0;
53
+ } else if (platform === 'linux') {
54
+ try {
55
+ const output = execFileSync('identify', ['-format', '%w %h', filepath], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
56
+ [width, height] = output.trim().split(' ').map(Number);
57
+ } catch {
58
+ // ImageMagick not available — skip downscaling.
59
+ return;
60
+ }
61
+ } else {
62
+ // Windows: no shipped downscale path.
63
+ return;
64
+ }
65
+
66
+ if (width <= maxDimension && height <= maxDimension) {
67
+ return;
68
+ }
69
+
70
+ if (platform === 'darwin') {
71
+ execFileSync('sips', ['-Z', String(maxDimension), filepath], { stdio: 'ignore' });
72
+ } else if (platform === 'linux') {
73
+ execFileSync('convert', [filepath, '-resize', `${maxDimension}x${maxDimension}>`, filepath], { stdio: 'ignore' });
74
+ }
75
+ } catch (_e) {
76
+ // Better to ship a too-big PNG than none.
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolve a user-supplied filename to an absolute path.
82
+ *
83
+ * - Absolute path → unchanged.
84
+ * - Relative path → joined with session dir (creating it if necessary).
85
+ * - Falsy (null / undefined / '') → auto-generated name in session dir.
86
+ */
87
+ function resolveScreenshotPath(filename) {
88
+ if (!filename) {
89
+ // Auto-generate a timestamped filename in the session dir.
90
+ const dir = initializeSession ? initializeSession() : (state && state.sessionDir) || process.cwd();
91
+ return path.join(dir, `screenshot-${Date.now()}.png`);
92
+ }
93
+
94
+ // Absolute: /foo/bar or C:\foo\bar (Windows).
95
+ if (path.isAbsolute(filename)) {
96
+ return filename;
97
+ }
98
+
99
+ // Relative: join with session dir.
100
+ let dir;
101
+ if (initializeSession) {
102
+ dir = initializeSession();
103
+ } else if (state && state.sessionDir) {
104
+ dir = state.sessionDir;
105
+ } else {
106
+ // No session context — fall back to CWD (legacy behaviour).
107
+ return path.resolve(filename);
108
+ }
109
+ return path.join(dir, filename);
110
+ }
111
+
112
+ async function screenshot(tabIndexOrWsUrl, filename, selector = null, fullPage = false) {
113
+ const resolvedFilename = resolveScreenshotPath(filename);
114
+ const pageSession = await getPageSession(tabIndexOrWsUrl);
115
+
116
+ let clip;
117
+ if (fullPage) {
118
+ const metrics = await pageSession.send('Page.getLayoutMetrics');
119
+ const { width, height } = metrics.contentSize;
120
+ clip = { x: 0, y: 0, width, height, scale: 1 };
121
+ } else if (selector) {
122
+ const js = `
123
+ (() => {
124
+ const el = ${getElementSelector(selector)};
125
+ if (!el) return null;
126
+ const rect = el.getBoundingClientRect();
127
+ return {
128
+ x: rect.left,
129
+ y: rect.top,
130
+ width: rect.width,
131
+ height: rect.height,
132
+ scale: 1
133
+ };
134
+ })()
135
+ `;
136
+ const result = await pageSession.send('Runtime.evaluate', {
137
+ expression: js,
138
+ returnByValue: true
139
+ });
140
+ throwIfExceptionDetails(result);
141
+ clip = result.result.value;
142
+ } else {
143
+ // Explicit viewport clip — required for correct sizing on Linux HiDPI.
144
+ const vpResult = await pageSession.send('Runtime.evaluate', {
145
+ expression: '({ width: window.innerWidth, height: window.innerHeight })',
146
+ returnByValue: true
147
+ });
148
+ throwIfExceptionDetails(vpResult);
149
+ const { width, height } = vpResult.result.value;
150
+ clip = { x: 0, y: 0, width, height, scale: 1 };
151
+ }
152
+
153
+ const result = await pageSession.send('Page.captureScreenshot', {
154
+ format: 'png',
155
+ fromSurface: true,
156
+ captureBeyondViewport: fullPage,
157
+ clip
158
+ });
159
+
160
+ const buffer = Buffer.from(result.data, 'base64');
161
+ fs.writeFileSync(resolvedFilename, buffer);
162
+
163
+ await downscaleImageIfNeeded(resolvedFilename, MAX_IMAGE_DIMENSION_PX);
164
+
165
+ return resolvedFilename;
166
+ }
167
+
168
+ return { screenshot };
169
+ }
170
+
171
+ module.exports = { attachScreenshot };