@dmsdc-ai/aigentry-deliberation 0.0.30 → 0.0.32

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.
@@ -12,6 +12,7 @@ import { readFileSync } from "node:fs";
12
12
  import { join, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { DegradationStateMachine, makeResult, ERROR_CODES } from "./degradation-state-machine.js";
15
+ import { writeClipboardText } from "./clipboard.js";
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
 
@@ -103,6 +104,33 @@ class DevToolsMcpAdapter extends BrowserControlPort {
103
104
  this._cmdId = 0;
104
105
  /** @type {Map<string, Set<string>>} dedupe: sessionId → Set<turnId> */
105
106
  this.sentTurns = new Map();
107
+ /** @type {Map<string, any>} wsUrl -> WebSocket connection promise */
108
+ this._connections = new Map();
109
+ }
110
+
111
+ async _getConnection(wsUrl) {
112
+ if (this._connections.has(wsUrl)) {
113
+ const conn = await this._connections.get(wsUrl);
114
+ if (conn.readyState === 1 || conn.readyState === 0) return conn; // OPEN or CONNECTING
115
+ this._connections.delete(wsUrl);
116
+ }
117
+
118
+ const connPromise = this._connectWs(wsUrl);
119
+ this._connections.set(wsUrl, connPromise);
120
+
121
+ try {
122
+ const ws = await connPromise;
123
+ // Handle cleanup if closed externally
124
+ if (ws.on) {
125
+ ws.on("close", () => this._connections.delete(wsUrl));
126
+ } else if (ws.addEventListener) {
127
+ ws.addEventListener("close", () => this._connections.delete(wsUrl));
128
+ }
129
+ return ws;
130
+ } catch (err) {
131
+ this._connections.delete(wsUrl);
132
+ throw err;
133
+ }
106
134
  }
107
135
 
108
136
  async attach(sessionId, targetHint = {}) {
@@ -241,6 +269,19 @@ class DevToolsMcpAdapter extends BrowserControlPort {
241
269
  binding._baselineDirectCount = baseline.data?.directCount || 0;
242
270
  binding._lastSentText = text; // Store for response filtering
243
271
 
272
+ // Mark existing containers with data-dlb-baseline for robust new-response detection
273
+ // (immune to content-visibility CSS count fluctuations)
274
+ await this._cdpEvaluate(binding, `
275
+ (function() {
276
+ var contSels = ${respContSel}.split(',').map(function(s) { return s.trim(); });
277
+ try { document.querySelectorAll('[data-dlb-baseline]').forEach(function(el) { el.removeAttribute('data-dlb-baseline'); }); } catch(e) {}
278
+ for (var i = 0; i < contSels.length; i++) {
279
+ try { document.querySelectorAll(contSels[i]).forEach(function(el) { el.setAttribute('data-dlb-baseline', '1'); }); } catch(e) {}
280
+ }
281
+ return { ok: true };
282
+ })()
283
+ `);
284
+
244
285
  // Step 1: Focus input and insert text
245
286
  const inputSel = JSON.stringify(binding.selectors.inputSelector);
246
287
  const sendBtnSel = JSON.stringify(binding.selectors.sendButton);
@@ -270,105 +311,194 @@ class DevToolsMcpAdapter extends BrowserControlPort {
270
311
  }
271
312
 
272
313
  const isCE = focusInput.data?.isContentEditable;
314
+ const isMac = process.platform === "darwin";
273
315
 
274
- if (isCE) {
275
- // For contenteditable: use CDP Input.insertText (works with tiptap/ProseMirror/Quill)
276
- // First clear existing content
316
+ // ── Step 1.1: Insert text ──
317
+ // For IME-heavy languages (Korean, Japanese, etc.) or complex editors (tiptap, ProseMirror),
318
+ // Input.insertText is the canonical CDP way to inject text while bypassing IME.
319
+ let insertionSuccess = false;
320
+
321
+ try {
322
+ // Strategy 1: Clear and insert via CDP (Reliable for most web apps)
277
323
  await this._cdpEvaluate(binding, `
278
324
  (function() {
279
325
  var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
280
- var input = null;
281
- for (var i = 0; i < sels.length; i++) { input = document.querySelector(sels[i]); if (input) break; }
282
- if (!input) return { ok: true };
283
- input.click();
284
- input.focus();
285
- var sel = window.getSelection();
286
- var range = document.createRange();
287
- range.selectNodeContents(input);
288
- sel.removeAllRanges();
289
- sel.addRange(range);
290
- document.execCommand('delete', false);
291
- return { ok: true };
326
+ for (var i = 0; i < sels.length; i++) {
327
+ var input = document.querySelector(sels[i]);
328
+ if (input) {
329
+ input.click();
330
+ input.focus();
331
+ if (input.isContentEditable) {
332
+ var sel = window.getSelection();
333
+ var range = document.createRange();
334
+ range.selectNodeContents(input);
335
+ sel.removeAllRanges();
336
+ sel.addRange(range);
337
+ document.execCommand('delete', false);
338
+ } else {
339
+ input.value = '';
340
+ }
341
+ return { ok: true };
342
+ }
343
+ }
344
+ return { ok: false };
292
345
  })()
293
346
  `);
294
- await new Promise(r => setTimeout(r, 50));
347
+
348
+ await this._cdpCommand(binding, "Input.insertText", { text });
349
+ await new Promise(r => setTimeout(r, 100));
350
+
351
+ // Sync framework state
352
+ await this._cdpEvaluate(binding, `
353
+ (function() {
354
+ var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
355
+ for (var i = 0; i < sels.length; i++) {
356
+ var input = document.querySelector(sels[i]);
357
+ if (input) {
358
+ input.dispatchEvent(new Event('input', { bubbles: true }));
359
+ input.dispatchEvent(new Event('change', { bubbles: true }));
360
+ }
361
+ }
362
+ })()
363
+ `);
364
+ insertionSuccess = true;
365
+ } catch (e) { /* ignore and try fallback */ }
295
366
 
296
- // Use CDP Input.insertText triggers all framework handlers (tiptap, ProseMirror, Quill)
367
+ // Strategy 2: Fallback to System Clipboard Paste (Cmd+V)
368
+ if (!insertionSuccess) {
297
369
  try {
298
- await this._cdpCommand(binding, "Input.insertText", { text });
299
- } catch {
300
- // Fallback: execCommand (works for tiptap when properly focused via click)
370
+ writeClipboardText(text);
371
+ const mod = isMac ? 4 : 2; // Meta (4) on Mac, Control (2) on Win/Linux
372
+ const keyCode = isMac ? 9 : 86; // 'V' key code
373
+
374
+ // On Mac, we can send a list of commands. "paste" is layout-independent.
375
+ const cdpParams = {
376
+ type: "keyDown",
377
+ modifiers: mod,
378
+ key: isMac ? "ㅍ" : "v", // Map to current layout if possible
379
+ code: "KeyV",
380
+ windowsVirtualKeyCode: 86,
381
+ nativeVirtualKeyCode: keyCode
382
+ };
383
+ if (isMac) cdpParams.commands = ["paste"];
384
+
385
+ await this._cdpCommand(binding, "Input.dispatchKeyEvent", cdpParams);
386
+
387
+ // Also send KeyUp
388
+ await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
389
+ type: "keyUp",
390
+ modifiers: mod,
391
+ key: isMac ? "ㅍ" : "v",
392
+ code: "KeyV",
393
+ windowsVirtualKeyCode: 86,
394
+ nativeVirtualKeyCode: keyCode
395
+ });
396
+
397
+ insertionSuccess = true;
398
+ await new Promise(r => setTimeout(r, 200));
399
+ } catch (clipErr) { /* ignore */ }
400
+ }
401
+
402
+ if (!insertionSuccess) {
403
+ if (isCE) {
404
+ // For contenteditable: use CDP Input.insertText (works with tiptap/ProseMirror/Quill)
405
+ // First clear existing content
301
406
  await this._cdpEvaluate(binding, `
302
407
  (function() {
303
408
  var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
304
- for (var i = 0; i < sels.length; i++) {
305
- var input = document.querySelector(sels[i]);
306
- if (input) { input.click(); input.focus(); document.execCommand('insertText', false, ${escapedText}); break; }
307
- }
409
+ var input = null;
410
+ for (var i = 0; i < sels.length; i++) { input = document.querySelector(sels[i]); if (input) break; }
411
+ if (!input) return { ok: true };
412
+ input.click();
413
+ input.focus();
414
+ var sel = window.getSelection();
415
+ var range = document.createRange();
416
+ range.selectNodeContents(input);
417
+ sel.removeAllRanges();
418
+ sel.addRange(range);
419
+ document.execCommand('delete', false);
308
420
  return { ok: true };
309
421
  })()
310
422
  `);
311
- }
312
- } else {
313
- // For regular <textarea>/<input>: clear existing content, then use CDP Input.insertText
314
- await this._cdpEvaluate(binding, `
315
- (function() {
316
- var input = document.querySelector(${inputSel});
317
- if (!input) return { ok: true };
318
- input.focus();
319
- input.select ? input.select() : null;
320
- input.value = '';
321
- input.dispatchEvent(new Event('input', { bubbles: true }));
322
- return { ok: true };
323
- })()
324
- `);
325
- await new Promise(r => setTimeout(r, 50));
423
+ await new Promise(r => setTimeout(r, 50));
326
424
 
327
- // CDP Input.insertText triggers OS-level text input
328
- try {
329
- await this._cdpCommand(binding, "Input.insertText", { text });
330
- } catch {
331
- // Fallback: nativeSetter approach
425
+ // Use CDP Input.insertText triggers all framework handlers (tiptap, ProseMirror, Quill)
426
+ try {
427
+ await this._cdpCommand(binding, "Input.insertText", { text });
428
+ } catch {
429
+ // Fallback: execCommand (works for tiptap when properly focused via click)
430
+ await this._cdpEvaluate(binding, `
431
+ (function() {
432
+ var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
433
+ for (var i = 0; i < sels.length; i++) {
434
+ var input = document.querySelector(sels[i]);
435
+ if (input) { input.click(); input.focus(); document.execCommand('insertText', false, ${escapedText}); break; }
436
+ }
437
+ return { ok: true };
438
+ })()
439
+ `);
440
+ }
441
+ } else {
442
+ // For regular <textarea>/<input>: clear existing content, then use CDP Input.insertText
332
443
  await this._cdpEvaluate(binding, `
333
444
  (function() {
334
445
  var input = document.querySelector(${inputSel});
335
446
  if (!input) return { ok: true };
336
- var nativeSetter = Object.getOwnPropertyDescriptor(
337
- Object.getPrototypeOf(input), 'value'
338
- );
339
- if (nativeSetter && nativeSetter.set) {
340
- nativeSetter.set.call(input, ${escapedText});
341
- } else {
342
- input.value = ${escapedText};
343
- }
447
+ input.focus();
448
+ input.select ? input.select() : null;
449
+ input.value = '';
344
450
  input.dispatchEvent(new Event('input', { bubbles: true }));
345
- input.dispatchEvent(new Event('change', { bubbles: true }));
346
451
  return { ok: true };
347
452
  })()
348
453
  `);
349
- }
454
+ await new Promise(r => setTimeout(r, 50));
350
455
 
351
- // Sync React/Vue state: CDP Input.insertText updates DOM but may not trigger React onChange.
352
- // Re-set value via nativeSetter + dispatch input event to force framework state sync.
353
- await this._cdpEvaluate(binding, `
354
- (function() {
355
- var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
356
- for (var i = 0; i < sels.length; i++) {
357
- var input = document.querySelector(sels[i]);
358
- if (input && input.value) {
359
- var proto = Object.getPrototypeOf(input);
360
- var desc = Object.getOwnPropertyDescriptor(proto, 'value');
361
- if (desc && desc.set) {
362
- desc.set.call(input, input.value);
456
+ // CDP Input.insertText triggers OS-level text input
457
+ try {
458
+ await this._cdpCommand(binding, "Input.insertText", { text });
459
+ } catch {
460
+ // Fallback: nativeSetter approach
461
+ await this._cdpEvaluate(binding, `
462
+ (function() {
463
+ var input = document.querySelector(${inputSel});
464
+ if (!input) return { ok: true };
465
+ var nativeSetter = Object.getOwnPropertyDescriptor(
466
+ Object.getPrototypeOf(input), 'value'
467
+ );
468
+ if (nativeSetter && nativeSetter.set) {
469
+ nativeSetter.set.call(input, ${escapedText});
470
+ } else {
471
+ input.value = ${escapedText};
363
472
  }
364
473
  input.dispatchEvent(new Event('input', { bubbles: true }));
365
474
  input.dispatchEvent(new Event('change', { bubbles: true }));
366
475
  return { ok: true };
476
+ })()
477
+ `);
478
+ }
479
+
480
+ // Sync React/Vue state: CDP Input.insertText updates DOM but may not trigger React onChange.
481
+ // Re-set value via nativeSetter + dispatch input event to force framework state sync.
482
+ await this._cdpEvaluate(binding, `
483
+ (function() {
484
+ var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
485
+ for (var i = 0; i < sels.length; i++) {
486
+ var input = document.querySelector(sels[i]);
487
+ if (input && input.value) {
488
+ var proto = Object.getPrototypeOf(input);
489
+ var desc = Object.getOwnPropertyDescriptor(proto, 'value');
490
+ if (desc && desc.set) {
491
+ desc.set.call(input, input.value);
492
+ }
493
+ input.dispatchEvent(new Event('input', { bubbles: true }));
494
+ input.dispatchEvent(new Event('change', { bubbles: true }));
495
+ return { ok: true };
496
+ }
367
497
  }
368
- }
369
- return { ok: true };
370
- })()
371
- `);
498
+ return { ok: true };
499
+ })()
500
+ `);
501
+ }
372
502
  }
373
503
 
374
504
  // Small delay for framework state propagation
@@ -527,6 +657,8 @@ class DevToolsMcpAdapter extends BrowserControlPort {
527
657
 
528
658
  const timeoutMs = timeoutSec * 1000;
529
659
  const pollInterval = binding.timing?.pollIntervalMs || 500;
660
+ const stabilizationThreshold = binding.timing?.stabilizationThreshold || 6;
661
+ const stabilizationMinWaitMs = binding.timing?.stabilizationMinWaitMs || 0;
530
662
  const startTime = Date.now();
531
663
 
532
664
  const streamSel = JSON.stringify(binding.selectors.streamingIndicator);
@@ -550,15 +682,31 @@ class DevToolsMcpAdapter extends BrowserControlPort {
550
682
  function isResponse(txt) {
551
683
  if (!txt || !txt.trim()) return false;
552
684
  var t = txt.trim();
685
+ var tLower = t.toLowerCase();
553
686
  // Reject if text is exact match of sent message (user echo)
554
687
  if (sentMsg && t === sentMsg) return false;
555
688
  // Reject if text is >80% similar to sent message (likely echo with minor formatting diff)
556
689
  if (sentMsg && t.length > 20 && (t.includes(sentMsg) || sentMsg === t.substring(0, sentMsg.length))) return false;
557
- // Reject known loading/placeholder texts
690
+ // Strip known suffixes before checking (Qwen appends skip button text)
691
+ var suffixes = ['건너뛰기', 'skip'];
692
+ for (var s = 0; s < suffixes.length; s++) {
693
+ if (tLower.length > suffixes[s].length && tLower.lastIndexOf(suffixes[s]) === tLower.length - suffixes[s].length) {
694
+ t = t.substring(0, t.length - suffixes[s].length).trim();
695
+ tLower = t.toLowerCase();
696
+ }
697
+ }
698
+ // Reject known loading/placeholder texts (case-insensitive)
558
699
  var placeholders = ['...', '…', '생각 중...', '생각 중', '생각이 끝났습니다',
559
- '조금만 더 기다려 주세요', '조금만 더 기다려 주세요.', 'Thinking', 'Thinking...',
560
- 'Loading', 'Loading...', 'Generating', 'Generating...'];
561
- if (placeholders.indexOf(t) >= 0) return false;
700
+ '조금만 더 기다려 주세요', '조금만 더 기다려 주세요.',
701
+ '응답을 준비 중입니다. 잠시만 기다려 주세요.', '응답을 준비 중입니다.', '잠시만 기다려 주세요.',
702
+ 'copilot 메시지', 'copilot message',
703
+ 'thinking', 'thinking...', 'loading', 'loading...', 'generating', 'generating...'];
704
+ if (placeholders.indexOf(tLower) >= 0) return false;
705
+ // Reject by prefix (loading/preparing messages)
706
+ var prefixes = ['응답을 준비', 'generating response'];
707
+ for (var p = 0; p < prefixes.length; p++) {
708
+ if (tLower.indexOf(prefixes[p].toLowerCase()) === 0) return false;
709
+ }
562
710
  // Reject very short text (likely placeholder dots or single chars)
563
711
  if (t.length < 2) return false;
564
712
  return true;
@@ -571,6 +719,7 @@ class DevToolsMcpAdapter extends BrowserControlPort {
571
719
  return txt.replace(/\s*(Copied|Copy|복사|복사됨|공유|Share)\s*$/gi, '')
572
720
  .replace(/\s*(오전|오후)\s+\d{1,2}:\d{2}\s*$/g, '') // Korean timestamps
573
721
  .replace(/\s*\d+\.\d+초\s*$/g, '') // Duration indicators
722
+ .replace(/\s*(건너뛰기|skip)\s*$/gi, '') // Qwen skip button text
574
723
  .trim();
575
724
  }
576
725
 
@@ -594,15 +743,19 @@ class DevToolsMcpAdapter extends BrowserControlPort {
594
743
  } catch(e) {}
595
744
  }
596
745
 
597
- if (allContainers.length > ${baselineContainers}) {
598
- var last = allContainers[allContainers.length - 1];
599
- // Scroll into view to force content-visibility rendering (ChatGPT workaround)
746
+ // Find NEW containers (without baseline marker — immune to content-visibility count fluctuations)
747
+ var newContainers = [];
748
+ for (var nc = 0; nc < allContainers.length; nc++) {
749
+ if (!allContainers[nc].hasAttribute('data-dlb-baseline')) newContainers.push(allContainers[nc]);
750
+ }
751
+ if (newContainers.length > 0) {
752
+ var last = newContainers[newContainers.length - 1];
753
+ // Scroll into view to force content-visibility rendering
600
754
  try { last.scrollIntoView({ block: 'end' }); } catch(e) {}
601
755
  var respSels = ${respSel}.split(',').map(function(s) { return s.trim(); });
602
756
  for (var i = 0; i < respSels.length; i++) {
603
757
  try {
604
758
  var content = last.querySelector(respSels[i]);
605
- // Try textContent first, then innerText as fallback for content-visibility
606
759
  var txt = content ? (content.textContent || content.innerText || '') : '';
607
760
  if (isResponse(txt)) {
608
761
  candidateText = txt;
@@ -630,14 +783,17 @@ class DevToolsMcpAdapter extends BrowserControlPort {
630
783
  for (var j = 0; j < found.length; j++) allDirect.push(found[j]);
631
784
  } catch(e) {}
632
785
  }
633
- if (allDirect.length > ${baselineDirect}) {
634
- for (var k = allDirect.length - 1; k >= ${baselineDirect}; k--) {
635
- var txt = allDirect[k].textContent?.trim();
636
- if (isResponse(txt)) {
637
- candidateText = txt;
638
- candidateStrategy = 'direct';
639
- break;
640
- }
786
+ // Filter to elements NOT inside a baseline-marked container
787
+ var newDirect = [];
788
+ for (var d = 0; d < allDirect.length; d++) {
789
+ if (!allDirect[d].closest('[data-dlb-baseline]')) newDirect.push(allDirect[d]);
790
+ }
791
+ for (var k = newDirect.length - 1; k >= 0; k--) {
792
+ var txt = newDirect[k].textContent?.trim();
793
+ if (isResponse(txt)) {
794
+ candidateText = txt;
795
+ candidateStrategy = 'direct';
796
+ break;
641
797
  }
642
798
  }
643
799
  }
@@ -651,29 +807,27 @@ class DevToolsMcpAdapter extends BrowserControlPort {
651
807
  })()
652
808
  `);
653
809
 
654
- if (status.data && !status.data.streaming && status.data.text) {
655
- return makeResult(true, {
656
- turnId,
657
- response: status.data.text.trim(),
658
- elapsedMs: Date.now() - startTime,
659
- strategy: status.data.strategy,
660
- });
661
- }
810
+ // Unified text tracking: always verify text stability before returning
811
+ // (prevents premature capture when streaming indicators are unreliable)
812
+ const currentText = (status.data?.text || status.data?.candidateText || '').trim();
813
+ const isCurrentlyStreaming = !!(status.data?.streaming);
662
814
 
663
- // Text stabilization: if streaming indicator persists but text hasn't changed for 3+ polls, consider done
664
- if (status.data?.candidateText) {
665
- if (status.data.candidateText === lastSeenText) {
815
+ if (currentText) {
816
+ if (currentText === lastSeenText) {
666
817
  stableCount++;
667
- if (stableCount >= 3) {
818
+ // Non-streaming: require 2 stable polls (1s) for fast return
819
+ // Streaming: require full stabilizationThreshold for thorough check
820
+ const effectiveThreshold = isCurrentlyStreaming ? stabilizationThreshold : Math.min(2, stabilizationThreshold);
821
+ if (stableCount >= effectiveThreshold && elapsed >= stabilizationMinWaitMs) {
668
822
  return makeResult(true, {
669
823
  turnId,
670
- response: status.data.candidateText.trim(),
824
+ response: currentText,
671
825
  elapsedMs: Date.now() - startTime,
672
- strategy: 'stabilized',
826
+ strategy: isCurrentlyStreaming ? 'stabilized' : (status.data?.strategy || 'non-streaming'),
673
827
  });
674
828
  }
675
829
  } else {
676
- lastSeenText = status.data.candidateText;
830
+ lastSeenText = currentText;
677
831
  stableCount = 0;
678
832
  }
679
833
  }
@@ -883,37 +1037,43 @@ class DevToolsMcpAdapter extends BrowserControlPort {
883
1037
  throw Object.assign(new Error("No WebSocket URL for CDP"), { code: "MCP_CHANNEL_CLOSED" });
884
1038
  }
885
1039
 
886
- // Use dynamic import for WebSocket (Node 18+ has it globally, or ws package)
887
- const ws = await this._connectWs(binding.wsUrl);
1040
+ const ws = await this._getConnection(binding.wsUrl);
888
1041
  const id = ++this._cmdId;
889
1042
 
890
1043
  return new Promise((resolve, reject) => {
1044
+ let cleanup;
1045
+
891
1046
  const timeout = setTimeout(() => {
892
- ws.close();
893
- reject(Object.assign(new Error("CDP command timeout"), { code: "TIMEOUT" }));
894
- }, 10000);
1047
+ if (cleanup) cleanup();
1048
+ reject(Object.assign(new Error(`CDP command timeout: ${method}`), { code: "TIMEOUT" }));
1049
+ }, 15000);
895
1050
 
896
- ws.onmessage = (event) => {
1051
+ const handleMessage = (event) => {
897
1052
  try {
898
1053
  const data = JSON.parse(typeof event === "string" ? event : event.data);
899
1054
  if (data.id === id) {
900
1055
  clearTimeout(timeout);
901
- ws.close();
1056
+ if (cleanup) cleanup();
1057
+
902
1058
  if (data.error) {
903
1059
  reject(Object.assign(new Error(data.error.message), { code: "SEND_FAILED" }));
904
1060
  } else {
905
1061
  resolve(data.result);
906
1062
  }
907
1063
  }
908
- } catch { /* ignore parse errors */ }
1064
+ } catch { /* parse error */ }
909
1065
  };
910
1066
 
911
- ws.onerror = (err) => {
912
- clearTimeout(timeout);
913
- ws.close();
914
- reject(Object.assign(new Error(err.message || "WebSocket error"), { code: "NETWORK_DISCONNECTED" }));
1067
+ cleanup = () => {
1068
+ if (ws.removeEventListener) ws.removeEventListener("message", handleMessage);
1069
+ else if (ws.off) ws.off("message", handleMessage);
1070
+ else if (ws.onmessage === handleMessage) ws.onmessage = null;
915
1071
  };
916
1072
 
1073
+ if (ws.addEventListener) ws.addEventListener("message", handleMessage);
1074
+ else if (ws.on) ws.on("message", handleMessage);
1075
+ else ws.onmessage = handleMessage;
1076
+
917
1077
  ws.send(JSON.stringify({ id, method, params }));
918
1078
  });
919
1079
  }