@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,499 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { getXdgCacheHome } = require('./chrome-launcher-helpers');
4
+ const { generateHtmlDiff } = require('./html-diff');
5
+ const { throwIfExceptionDetails } = require('./cdp-utils');
6
+ const markdownScript = require('./page-scripts/markdown');
7
+ const domSummaryScript = require('./page-scripts/dom-summary');
8
+
9
+ // Module-level registry of active session-cleanup callbacks.
10
+ // Per-session initializeSession adds its bound cleanup to the set;
11
+ // cleanupSession removes itself when it runs.
12
+ //
13
+ // Process exit handlers are registered exactly once for the whole module
14
+ // (not per session), so multiple ChromeSession instances in one process
15
+ // don't accumulate N×3 handlers.
16
+ const activeCleanups = new Set();
17
+ let processHandlersRegistered = false;
18
+
19
+ function ensureProcessHandlersRegistered() {
20
+ if (processHandlersRegistered) return;
21
+ processHandlersRegistered = true;
22
+ const runAll = () => { for (const fn of activeCleanups) fn(); };
23
+ process.on('exit', runAll);
24
+ process.on('SIGINT', () => { runAll(); process.exit(0); });
25
+ process.on('SIGTERM', () => { runAll(); process.exit(0); });
26
+ }
27
+
28
+ /**
29
+ * Auto-capture: every DOM-mutating action drops a {prefix}.html / .md / .png /
30
+ * -console.txt set into the session directory so the user (or model) can
31
+ * read what the page looked like instead of re-querying via CDP. The
32
+ * session dir is XDG-rooted at ~/.cache/moe/browser/YYYY-MM-DD/
33
+ * session-{timestamp} and is cleaned up on process exit / SIGINT / SIGTERM.
34
+ *
35
+ * Three layers:
36
+ * - Session lifecycle: initializeSession, cleanupSession, createCapturePrefix.
37
+ * - Page extractors: generateDomSummary, getPageSize, generateMarkdown.
38
+ * - Capture primitives: capturePageArtifacts (post-action snapshot) and
39
+ * captureActionWithDiff (before/after pair with HTML diff and saved
40
+ * focus restoration around the screenshot).
41
+ * - WithCapture wrappers: thin adapters that pair an action with a
42
+ * post-action capturePageArtifacts.
43
+ *
44
+ * `attachCapture({ state, getPageSession, getHtml,
45
+ * screenshot, actions: { click, fill, selectOption, evaluate } })`
46
+ * returns the bound API.
47
+ */
48
+ function attachCapture({ state, getPageSession, getHtml, screenshot, actions, dialogs }) {
49
+ const { renderSyntheticArtifacts } = require('./dialogs-render.js');
50
+ function initializeSession() {
51
+ if (!state.sessionDir) {
52
+ // ~/.cache/moe/browser/YYYY-MM-DD/session-{timestamp}
53
+ const cacheHome = getXdgCacheHome();
54
+ const dateStr = new Date().toISOString().split('T')[0];
55
+ const sessionId = `session-${Date.now()}`;
56
+
57
+ state.sessionDir = path.join(cacheHome, 'moe', 'browser', dateStr, sessionId);
58
+ fs.mkdirSync(state.sessionDir, { recursive: true });
59
+ state.captureCounter = 0;
60
+
61
+ console.error(`Browser session directory: ${state.sessionDir}`);
62
+
63
+ ensureProcessHandlersRegistered();
64
+ activeCleanups.add(cleanupSession);
65
+ }
66
+ return state.sessionDir;
67
+ }
68
+
69
+ function cleanupSession() {
70
+ if (state.sessionDir) {
71
+ try {
72
+ fs.rmSync(state.sessionDir, { recursive: true, force: true });
73
+ console.error(`Cleaned up session directory: ${state.sessionDir}`);
74
+ } catch (error) {
75
+ console.error(`Failed to cleanup session directory: ${error.message}`);
76
+ }
77
+ state.sessionDir = null;
78
+ }
79
+ activeCleanups.delete(cleanupSession);
80
+ }
81
+
82
+ function createCapturePrefix(actionType = 'navigate') {
83
+ initializeSession();
84
+ state.captureCounter++;
85
+ return `${String(state.captureCounter).padStart(3, '0')}-${actionType}`;
86
+ }
87
+
88
+ // Token-efficient page summary: heading list, interactive-element counts,
89
+ // main/nav landmark detection. Used in the auto-capture artifact bundle so
90
+ // the model can decide whether to read the .md or .html file.
91
+ async function generateDomSummary(tabIndexOrWsUrl) {
92
+ const ps = await getPageSession(tabIndexOrWsUrl);
93
+ const result = await ps.send('Runtime.evaluate', {
94
+ expression: domSummaryScript,
95
+ returnByValue: true
96
+ });
97
+ throwIfExceptionDetails(result);
98
+ return result.result.value;
99
+ }
100
+
101
+ async function getPageSize(tabIndexOrWsUrl) {
102
+ const ps = await getPageSession(tabIndexOrWsUrl);
103
+
104
+ const js = `({
105
+ width: window.innerWidth,
106
+ height: window.innerHeight,
107
+ documentWidth: document.documentElement.scrollWidth,
108
+ documentHeight: document.documentElement.scrollHeight
109
+ })`;
110
+
111
+ const result = await ps.send('Runtime.evaluate', {
112
+ expression: js,
113
+ returnByValue: true
114
+ });
115
+ throwIfExceptionDetails(result);
116
+ return result.result.value;
117
+ }
118
+
119
+ // Render the page to markdown for token-efficient consumption. Includes
120
+ // images >= 100x100 in a header summary; inlines image references >= 50x50
121
+ // with size info; skips smaller icons.
122
+ async function generateMarkdown(tabIndexOrWsUrl) {
123
+ const ps = await getPageSession(tabIndexOrWsUrl);
124
+ const result = await ps.send('Runtime.evaluate', {
125
+ expression: markdownScript,
126
+ returnByValue: true
127
+ });
128
+ throwIfExceptionDetails(result);
129
+ return result.result.value;
130
+ }
131
+
132
+ // Write content to a file inside dir, silently skipping if dir doesn't exist.
133
+ function writeIfDir(dir, filename, content) {
134
+ if (!dir) return;
135
+ try {
136
+ fs.writeFileSync(path.join(dir, filename), content);
137
+ } catch (_err) {
138
+ // Best-effort; missing session dir is not fatal.
139
+ }
140
+ }
141
+
142
+ // Single post-action snapshot: html + markdown + screenshot + console-log
143
+ // placeholder, all parallelised. Filenames share a numbered prefix so the
144
+ // session dir reads like a flat timeline.
145
+ async function capturePageArtifacts(tabIndexOrWsUrl, actionType = 'navigate') {
146
+ const ps = await getPageSession(tabIndexOrWsUrl);
147
+
148
+ // Dialog short-circuit: when a native browser dialog is open on this tab,
149
+ // return synthetic artifacts without issuing any CDP calls to the page.
150
+ if (dialogs) {
151
+ const open = dialogs.getOpen(ps.sessionId);
152
+ if (open) {
153
+ const artifacts = renderSyntheticArtifacts(open);
154
+ const prefix = createCapturePrefix(actionType);
155
+ const dir = state.sessionDir;
156
+ writeIfDir(dir, `${prefix}.md`, artifacts.markdown);
157
+ writeIfDir(dir, `${prefix}.html`, artifacts.html);
158
+ writeIfDir(dir, `${prefix}-console.txt`, artifacts.consoleSnapshot);
159
+ return {
160
+ capturePrefix: prefix,
161
+ sessionDir: dir,
162
+ files: {
163
+ html: dir ? path.join(dir, `${prefix}.html`) : null,
164
+ markdown: dir ? path.join(dir, `${prefix}.md`) : null,
165
+ screenshot: null,
166
+ consoleLog: dir ? path.join(dir, `${prefix}-console.txt`) : null,
167
+ },
168
+ markdown: artifacts.markdown,
169
+ html: artifacts.html,
170
+ consoleSnapshot: artifacts.consoleSnapshot,
171
+ png: undefined,
172
+ dialog: open,
173
+ };
174
+ }
175
+ }
176
+
177
+ const prefix = createCapturePrefix(actionType);
178
+ const dir = initializeSession();
179
+
180
+ const [html, markdown, pageSize, domSummary] = await Promise.all([
181
+ getHtml(tabIndexOrWsUrl),
182
+ generateMarkdown(tabIndexOrWsUrl),
183
+ getPageSize(tabIndexOrWsUrl),
184
+ generateDomSummary(tabIndexOrWsUrl)
185
+ ]);
186
+
187
+ const htmlPath = path.join(dir, `${prefix}.html`);
188
+ const markdownPath = path.join(dir, `${prefix}.md`);
189
+ const screenshotPath = path.join(dir, `${prefix}.png`);
190
+ const consoleLogPath = path.join(dir, `${prefix}-console.txt`);
191
+
192
+ fs.writeFileSync(htmlPath, html || '');
193
+ fs.writeFileSync(markdownPath, markdown || '');
194
+ fs.writeFileSync(consoleLogPath, '# Console Log\n# TODO: Console logging not yet implemented\n');
195
+
196
+ await screenshot(tabIndexOrWsUrl, screenshotPath);
197
+
198
+ return {
199
+ capturePrefix: prefix,
200
+ sessionDir: dir,
201
+ files: {
202
+ html: htmlPath,
203
+ markdown: markdownPath,
204
+ screenshot: screenshotPath,
205
+ consoleLog: consoleLogPath
206
+ },
207
+ pageSize,
208
+ domSummary
209
+ };
210
+ }
211
+
212
+ // Before/after capture pair with HTML diff. Wraps an actionFn so callers
213
+ // get the action result alongside the diff and screenshots. Saves and
214
+ // restores focus around the BEFORE screenshot — taking a screenshot can
215
+ // shift focus, which then breaks any focus-dependent action that follows.
216
+ async function captureActionWithDiff(tabIndexOrWsUrl, actionType, actionFn, settleTime = 3000) {
217
+ const ps = await getPageSession(tabIndexOrWsUrl);
218
+
219
+ // Pin the tab handle to the targetId resolved NOW so that a popup spawned
220
+ // by the action does not shift "tab 0" before the AFTER-capture runs
221
+ // (Bug 3 fix: resolve once at action start, use throughout).
222
+ const pinnedTab = { id: ps.targetId };
223
+
224
+ // If a dialog is open, skip BEFORE-capture entirely. The page's execution
225
+ // context is suspended (e.g. waiting for basic-auth credentials), so any
226
+ // Runtime.evaluate call would hang until timeout. The inner action handles
227
+ // dialog routing via withDialogAwarenessForSession.
228
+ if (dialogs && dialogs.getOpen(ps.sessionId)) {
229
+ return { actionResult: await actionFn() };
230
+ }
231
+
232
+ const prefix = createCapturePrefix(actionType);
233
+ const dir = initializeSession();
234
+
235
+ async function saveFocus() {
236
+ const result = await ps.send('Runtime.evaluate', {
237
+ expression: `
238
+ (() => {
239
+ const el = document.activeElement;
240
+ if (!el || el === document.body) return null;
241
+ // Build a unique selector for the focused element
242
+ if (el.id) return { type: 'id', value: el.id };
243
+ if (el.name) return { type: 'name', value: el.name, tag: el.tagName.toLowerCase() };
244
+ // Fallback: sibling-index path from body
245
+ const focusPath = [];
246
+ let current = el;
247
+ while (current && current !== document.body) {
248
+ const parent = current.parentElement;
249
+ if (!parent) break;
250
+ const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
251
+ const index = siblings.indexOf(current);
252
+ focusPath.unshift({ tag: current.tagName.toLowerCase(), index });
253
+ current = parent;
254
+ }
255
+ return { type: 'path', value: focusPath };
256
+ })()
257
+ `,
258
+ returnByValue: true
259
+ });
260
+ throwIfExceptionDetails(result);
261
+ return result.result?.value;
262
+ }
263
+
264
+ async function restoreFocus(focusInfo) {
265
+ if (!focusInfo) return;
266
+ let selector;
267
+ if (focusInfo.type === 'id') {
268
+ selector = `document.getElementById(${JSON.stringify(focusInfo.value)})`;
269
+ } else if (focusInfo.type === 'name') {
270
+ selector = `document.querySelector(${JSON.stringify(focusInfo.tag + '[name="' + focusInfo.value + '"]')})`;
271
+ } else if (focusInfo.type === 'path') {
272
+ selector = `(() => {
273
+ let el = document.body;
274
+ const focusPath = ${JSON.stringify(focusInfo.value)};
275
+ for (const step of focusPath) {
276
+ const children = Array.from(el.children).filter(c => c.tagName.toLowerCase() === step.tag);
277
+ el = children[step.index];
278
+ if (!el) return null;
279
+ }
280
+ return el;
281
+ })()`;
282
+ }
283
+ if (selector) {
284
+ const restoreResult = await ps.send('Runtime.evaluate', {
285
+ // preventScroll: true avoids scrolling the page to bring the
286
+ // re-focused element into view, which would undo any explicit
287
+ // scroll() the user just performed (Bug 4 fix).
288
+ expression: `(() => { const el = ${selector}; if (el) el.focus({ preventScroll: true }); })()`
289
+ });
290
+ throwIfExceptionDetails(restoreResult);
291
+ }
292
+ }
293
+
294
+ // BEFORE: html + screenshot, with focus saved/restored around the screenshot.
295
+ // Use pinnedTab throughout so a popup spawned mid-action doesn't redirect
296
+ // capture to the wrong tab.
297
+ const beforeHtml = await getHtml(pinnedTab);
298
+ const focusInfo = await saveFocus();
299
+ const beforeScreenshotPath = path.join(dir, `${prefix}-before.png`);
300
+ await screenshot(pinnedTab, beforeScreenshotPath);
301
+ await restoreFocus(focusInfo);
302
+
303
+ const actionResult = await actionFn();
304
+
305
+ // AFTER-capture short-circuit: if the action opened a dialog, skip the
306
+ // AFTER-capture to avoid Runtime.evaluate hangs while the page is suspended.
307
+ // Return the action result plus a synthetic dialog artifact so the caller
308
+ // sees a clean "dialog now open" response rather than a timeout.
309
+ if (dialogs) {
310
+ const openAfter = dialogs.getOpen(ps.sessionId);
311
+ if (openAfter) {
312
+ const artifacts = renderSyntheticArtifacts(openAfter);
313
+ const afterPrefix = createCapturePrefix(actionType);
314
+ const dir = state.sessionDir;
315
+ writeIfDir(dir, `${afterPrefix}.md`, artifacts.markdown);
316
+ writeIfDir(dir, `${afterPrefix}.html`, artifacts.html);
317
+ writeIfDir(dir, `${afterPrefix}-console.txt`, artifacts.consoleSnapshot);
318
+ return {
319
+ actionResult,
320
+ capture: null,
321
+ dialog: openAfter,
322
+ artifacts,
323
+ };
324
+ }
325
+ }
326
+
327
+ // Settle: lets React re-renders, animations, and post-action XHRs complete
328
+ // before the AFTER snapshot.
329
+ await new Promise(resolve => setTimeout(resolve, settleTime));
330
+
331
+ const [afterHtml, markdown, pageSize, domSummary] = await Promise.all([
332
+ getHtml(pinnedTab),
333
+ generateMarkdown(pinnedTab),
334
+ getPageSize(pinnedTab),
335
+ generateDomSummary(pinnedTab)
336
+ ]);
337
+
338
+ const diff = generateHtmlDiff(beforeHtml, afterHtml);
339
+
340
+ const beforeHtmlPath = path.join(dir, `${prefix}-before.html`);
341
+ const afterHtmlPath = path.join(dir, `${prefix}-after.html`);
342
+ const diffPath = path.join(dir, `${prefix}-diff.txt`);
343
+ const markdownPath = path.join(dir, `${prefix}.md`);
344
+ const afterScreenshotPath = path.join(dir, `${prefix}-after.png`);
345
+
346
+ fs.writeFileSync(beforeHtmlPath, beforeHtml || '');
347
+ fs.writeFileSync(afterHtmlPath, afterHtml || '');
348
+ fs.writeFileSync(diffPath, diff);
349
+ fs.writeFileSync(markdownPath, markdown || '');
350
+ await screenshot(pinnedTab, afterScreenshotPath);
351
+
352
+ return {
353
+ actionResult,
354
+ capture: {
355
+ prefix,
356
+ sessionDir: dir,
357
+ files: {
358
+ beforeHtml: beforeHtmlPath,
359
+ afterHtml: afterHtmlPath,
360
+ diff: diffPath,
361
+ markdown: markdownPath,
362
+ beforeScreenshot: beforeScreenshotPath,
363
+ afterScreenshot: afterScreenshotPath
364
+ },
365
+ pageSize,
366
+ domSummary,
367
+ diffSummary: diff.split('\n').slice(0, 5).join('\n') + (diff.split('\n').length > 5 ? '\n...' : '')
368
+ }
369
+ };
370
+ }
371
+
372
+ // *WithCapture wrappers — perform an action, then capturePageArtifacts.
373
+ // The MCP server consumes these directly; the bare action variants stay
374
+ // exported for callers (and tests) that don't want auto-capture.
375
+ async function clickWithCapture(tabIndexOrWsUrl, selector) {
376
+ const ps = await getPageSession(tabIndexOrWsUrl);
377
+ const run = async () => {
378
+ const clickResult = await actions.click(tabIndexOrWsUrl, selector);
379
+
380
+ // dialog::* selectors handle a native dialog (accept/dismiss). After the
381
+ // dialog is handled the page may immediately navigate or resume execution,
382
+ // so issuing Runtime.evaluate for a capture would race against that and
383
+ // timeout. Skip post-action capture; the next real page action will
384
+ // capture the settled state.
385
+ if (typeof selector === 'string' && selector.startsWith('dialog::')) {
386
+ return { action: 'click', selector, dialogHandled: true, result: clickResult };
387
+ }
388
+
389
+ // Pin the page session by targetId so a newly-spawned popup does not
390
+ // change what "tab 0" resolves to between the action and the capture
391
+ // (Bug 3 fix: resolve once, pass the stable tab handle forward).
392
+ const pinnedTab = { id: ps.targetId };
393
+ const artifacts = await capturePageArtifacts(pinnedTab, 'click');
394
+ return {
395
+ action: 'click',
396
+ selector,
397
+ pageSize: artifacts.pageSize,
398
+ capturePrefix: artifacts.capturePrefix,
399
+ sessionDir: artifacts.sessionDir,
400
+ files: artifacts.files,
401
+ domSummary: artifacts.domSummary,
402
+ consoleLog: [] // Placeholder
403
+ };
404
+ };
405
+ if (dialogs && dialogs.withDialogAwarenessForSession) {
406
+ return dialogs.withDialogAwarenessForSession('click', ps, { selector }, run);
407
+ }
408
+ return run();
409
+ }
410
+
411
+ async function fillWithCapture(tabIndexOrWsUrl, selector, value) {
412
+ const ps = await getPageSession(tabIndexOrWsUrl);
413
+ const pinnedTab = { id: ps.targetId };
414
+ const run = async () => {
415
+ await actions.fill(tabIndexOrWsUrl, selector, value);
416
+ const artifacts = await capturePageArtifacts(pinnedTab, 'type');
417
+ return {
418
+ action: 'type',
419
+ selector,
420
+ value,
421
+ pageSize: artifacts.pageSize,
422
+ capturePrefix: artifacts.capturePrefix,
423
+ sessionDir: artifacts.sessionDir,
424
+ files: artifacts.files,
425
+ domSummary: artifacts.domSummary,
426
+ consoleLog: [] // Placeholder
427
+ };
428
+ };
429
+ if (dialogs && dialogs.withDialogAwarenessForSession) {
430
+ return dialogs.withDialogAwarenessForSession('type', ps, { selector }, run);
431
+ }
432
+ return run();
433
+ }
434
+
435
+ async function selectOptionWithCapture(tabIndexOrWsUrl, selector, value) {
436
+ const ps = await getPageSession(tabIndexOrWsUrl);
437
+ const pinnedTab = { id: ps.targetId };
438
+ const run = async () => {
439
+ await actions.selectOption(tabIndexOrWsUrl, selector, value);
440
+ const artifacts = await capturePageArtifacts(pinnedTab, 'select');
441
+ return {
442
+ action: 'select',
443
+ selector,
444
+ value,
445
+ pageSize: artifacts.pageSize,
446
+ capturePrefix: artifacts.capturePrefix,
447
+ sessionDir: artifacts.sessionDir,
448
+ files: artifacts.files,
449
+ domSummary: artifacts.domSummary,
450
+ consoleLog: [] // Placeholder
451
+ };
452
+ };
453
+ if (dialogs && dialogs.withDialogAwarenessForSession) {
454
+ return dialogs.withDialogAwarenessForSession('select', ps, { selector }, run);
455
+ }
456
+ return run();
457
+ }
458
+
459
+ async function evaluateWithCapture(tabIndexOrWsUrl, expression) {
460
+ const ps = await getPageSession(tabIndexOrWsUrl);
461
+ const pinnedTab = { id: ps.targetId };
462
+ const run = async () => {
463
+ const result = await actions.evaluate(tabIndexOrWsUrl, expression);
464
+ const artifacts = await capturePageArtifacts(pinnedTab, 'eval');
465
+ return {
466
+ action: 'eval',
467
+ expression,
468
+ result,
469
+ pageSize: artifacts.pageSize,
470
+ capturePrefix: artifacts.capturePrefix,
471
+ sessionDir: artifacts.sessionDir,
472
+ files: artifacts.files,
473
+ domSummary: artifacts.domSummary,
474
+ consoleLog: [] // Placeholder
475
+ };
476
+ };
477
+ if (dialogs && dialogs.withDialogAwarenessForSession) {
478
+ return dialogs.withDialogAwarenessForSession('eval', ps, {}, run);
479
+ }
480
+ return run();
481
+ }
482
+
483
+ return {
484
+ initializeSession,
485
+ cleanupSession,
486
+ createCapturePrefix,
487
+ generateDomSummary,
488
+ getPageSize,
489
+ generateMarkdown,
490
+ capturePageArtifacts,
491
+ captureActionWithDiff,
492
+ clickWithCapture,
493
+ fillWithCapture,
494
+ selectOptionWithCapture,
495
+ evaluateWithCapture,
496
+ };
497
+ }
498
+
499
+ module.exports = { attachCapture };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * createCdpRouter({browser}) — sessionId-aware dispatcher for browser-WS messages.
5
+ *
6
+ * Routing rules:
7
+ * - msg.sessionId set -> per-session pendingRequests (if msg.id) or eventListeners (if msg.method)
8
+ * - msg.method, no sessionId -> root listeners (target events, etc.)
9
+ * - msg.id, no sessionId -> falls through (browser-session.js owns root correlation)
10
+ *
11
+ * Per-session message-id counters are independent. {id:1, sessionId:"A"} and
12
+ * {id:1, sessionId:"B"} correlate independently on one WS — collapsing id space
13
+ * across sessions would silently break correlation.
14
+ */
15
+ function createCdpRouter({ browser }) {
16
+ const sessions = new Map(); // sessionId -> { pendingRequests, eventListeners }
17
+ const rootListeners = new Set();
18
+
19
+ browser.onEvent((msg) => {
20
+ const sid = msg.sessionId;
21
+ if (sid) {
22
+ const sess = sessions.get(sid);
23
+ if (!sess) return; // detached or never registered — drop silently
24
+ if (msg.id !== undefined) {
25
+ const pending = sess.pendingRequests.get(msg.id);
26
+ if (pending) {
27
+ clearTimeout(pending.timeout);
28
+ sess.pendingRequests.delete(msg.id);
29
+ if (msg.error) {
30
+ pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
31
+ } else {
32
+ pending.resolve(msg.result);
33
+ }
34
+ }
35
+ } else if (msg.method) {
36
+ for (const fn of sess.eventListeners) {
37
+ try { fn(msg); } catch (e) { console.error('cdp-router page listener threw:', e); }
38
+ }
39
+ }
40
+ } else if (msg.method) {
41
+ for (const fn of rootListeners) {
42
+ try { fn(msg); } catch (e) { console.error('cdp-router root listener threw:', e); }
43
+ }
44
+ }
45
+ // Untagged responses (msg.id with no sessionId, no method) intentionally
46
+ // fall through — browser-session.js's pendingRequests Map handles them.
47
+ });
48
+
49
+ function registerSession(sessionId) {
50
+ const sess = { pendingRequests: new Map(), eventListeners: new Set() };
51
+ sessions.set(sessionId, sess);
52
+ return sess;
53
+ }
54
+
55
+ function unregisterSession(sessionId) {
56
+ const sess = sessions.get(sessionId);
57
+ if (!sess) return;
58
+ for (const [, p] of sess.pendingRequests) {
59
+ clearTimeout(p.timeout);
60
+ p.reject(new Error('Page session detached'));
61
+ }
62
+ sess.pendingRequests.clear();
63
+ sess.eventListeners.clear();
64
+ sessions.delete(sessionId);
65
+ }
66
+
67
+ function getRootListeners() { return rootListeners; }
68
+
69
+ return { registerSession, unregisterSession, getRootListeners };
70
+ }
71
+
72
+ module.exports = { createCdpRouter };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared utilities for CDP responses.
3
+ *
4
+ * `throwIfExceptionDetails(result)` inspects a `Runtime.evaluate` reply and
5
+ * throws if the page-side JS threw or a Promise rejected. Without this,
6
+ * callers silently see `undefined` instead of the actual error — which has
7
+ * caused real bugs (waitForElement timeouts swallowed, evaluate returning {}
8
+ * for thrown errors). Use after every `sendCdpCommand(...,'Runtime.evaluate',...)`.
9
+ */
10
+ function throwIfExceptionDetails(result) {
11
+ if (!result || !result.exceptionDetails) return;
12
+ const desc = result.exceptionDetails.exception?.description
13
+ || result.exceptionDetails.text
14
+ || 'unknown evaluation error';
15
+ throw new Error(`evaluate failed: ${desc}`);
16
+ }
17
+
18
+ module.exports = { throwIfExceptionDetails };