baa-conductor


baa-conductor / plugins / baa-firefox
codex@macbookpro  ·  2026-03-31

delivery-adapters.js

  1(function initBaaDeliveryAdapters(globalScope) {
  2  const DEFAULT_TIMEOUT_MS = 30_000;
  3  const DEFAULT_POLL_INTERVAL_MS = 150;
  4  const DEFAULT_RETRY_ATTEMPTS = 2;
  5  const DEFAULT_RETRY_DELAY_MS = 250;
  6  const PLATFORM_ADAPTERS = {
  7    claude: {
  8      label: "Claude",
  9      pageHosts: ["claude.ai"],
 10      readinessSelectors: [
 11        "main",
 12        "form",
 13        "[data-testid*='composer' i]",
 14        "div.ProseMirror[contenteditable='true']",
 15        "[role='textbox']"
 16      ],
 17      composerSelectors: [
 18        "div[contenteditable='true'][data-testid*='composer']",
 19        "div[contenteditable='true'][aria-label*='message' i]",
 20        "div[contenteditable='true'][role='textbox']",
 21        "div.ProseMirror[contenteditable='true']",
 22        "textarea"
 23      ],
 24      sendButtonSelectors: [
 25        "button[data-testid*='send' i]",
 26        "button[aria-label*='send' i]",
 27        "form button[type='submit']"
 28      ],
 29      sendingSelectors: [
 30        "button[data-testid*='stop' i]",
 31        "button[aria-label*='stop' i]",
 32        "button[aria-label*='cancel' i]"
 33      ]
 34    },
 35    chatgpt: {
 36      label: "ChatGPT",
 37      pageHosts: ["chatgpt.com", "chat.openai.com"],
 38      readinessSelectors: [
 39        "main",
 40        "form",
 41        "#prompt-textarea",
 42        "[data-testid='prompt-textarea']",
 43        "[role='textbox']"
 44      ],
 45      composerSelectors: [
 46        "#prompt-textarea",
 47        "textarea[data-testid='prompt-textarea']",
 48        "div[contenteditable='true'][data-testid='prompt-textarea']",
 49        "div[contenteditable='true'][role='textbox']",
 50        "textarea"
 51      ],
 52      sendButtonSelectors: [
 53        "button[data-testid='send-button']",
 54        "button[aria-label*='send prompt' i]",
 55        "button[aria-label*='send message' i]",
 56        "form button[type='submit']"
 57      ],
 58      sendingSelectors: [
 59        "button[data-testid='stop-button']",
 60        "button[aria-label*='stop generating' i]",
 61        "button[aria-label*='stop' i]"
 62      ]
 63    },
 64    gemini: {
 65      label: "Gemini",
 66      pageHosts: ["gemini.google.com"],
 67      readinessSelectors: [
 68        ".conversation-container",
 69        "chat-window",
 70        "rich-text-field",
 71        "rich-textarea",
 72        ".ql-editor[contenteditable='true']",
 73        "[role='textbox']"
 74      ],
 75      composerSelectors: [
 76        "rich-text-field .ql-editor[contenteditable='true']",
 77        "rich-textarea .ql-editor[contenteditable='true']",
 78        "rich-text-field div[contenteditable='true']",
 79        "rich-textarea div[contenteditable='true']",
 80        "div[contenteditable='true'][aria-label*='prompt' i]",
 81        "div[contenteditable='true'][aria-label*='message' i]",
 82        "div[contenteditable='true'][role='textbox']",
 83        "textarea[aria-label*='prompt' i]",
 84        "textarea"
 85      ],
 86      sendButtonSelectors: [
 87        "button[aria-label*='Send message' i]",
 88        "button[aria-label*='send' i]",
 89        "button[mattooltip*='send' i]",
 90        ".send-button",
 91        ".input-area-container button:not([aria-label*='microphone' i])"
 92      ],
 93      sendingSelectors: [
 94        "button[aria-label*='Stop' i]",
 95        "button[aria-label*='Cancel' i]",
 96        "mat-icon[data-mat-icon-name='stop_circle']"
 97      ]
 98    }
 99  };
100
101  class DeliveryError extends Error {
102    constructor(code, message, details = {}) {
103      super(message);
104      this.name = "DeliveryError";
105      this.code = trimToNull(code) || "command_failed";
106      this.details = isRecord(details) ? details : {};
107    }
108  }
109
110  function isRecord(value) {
111    return value !== null && typeof value === "object" && !Array.isArray(value);
112  }
113
114  function trimToNull(value) {
115    if (typeof value !== "string") {
116      return null;
117    }
118
119    const normalized = value.trim();
120    return normalized === "" ? null : normalized;
121  }
122
123  function toPositiveInteger(value, fallback) {
124    const numeric = Number(value);
125    if (!Number.isFinite(numeric) || numeric <= 0) {
126      return fallback;
127    }
128
129    return Math.max(1, Math.floor(numeric));
130  }
131
132  function normalizeText(value) {
133    return String(value || "")
134      .replace(/\s+/gu, " ")
135      .trim()
136      .toLowerCase();
137  }
138
139  function normalizeError(error, fallbackMessage, details = {}) {
140    if (error instanceof DeliveryError) {
141      return error;
142    }
143
144    const message = error instanceof Error ? error.message : trimToNull(String(error)) || fallbackMessage;
145    return new DeliveryError("command_failed", message, details);
146  }
147
148  function formatFailureReason(error) {
149    const normalized = normalizeDeliveryError(error, {});
150    return `delivery.${normalized.code}: ${normalized.message}`;
151  }
152
153  function normalizeDeliveryError(error, details = {}) {
154    const normalized = normalizeError(error, "delivery command failed", details);
155
156    if (!normalized.details.attempt && details.attempt) {
157      normalized.details.attempt = details.attempt;
158    }
159    if (!normalized.details.attempts && details.attempts) {
160      normalized.details.attempts = details.attempts;
161    }
162
163    return normalized;
164  }
165
166  function isElementLike(value) {
167    return value != null && typeof value === "object" && typeof value.getBoundingClientRect === "function";
168  }
169
170  function isButtonLike(value) {
171    return isElementLike(value) && String(value.tagName || "").toUpperCase() === "BUTTON";
172  }
173
174  function isInputLike(value) {
175    const tagName = String(value?.tagName || "").toUpperCase();
176    return isElementLike(value) && (tagName === "INPUT" || tagName === "TEXTAREA");
177  }
178
179  function isContentEditableLike(value) {
180    return isElementLike(value) && value.isContentEditable === true;
181  }
182
183  function createBrowserEnv(overrides = {}) {
184    const documentRef = overrides.document || globalScope.document || null;
185    const locationRef = overrides.location || globalScope.location || null;
186
187    return {
188      createChangeEvent: overrides.createChangeEvent || (() => new globalScope.Event("change", {
189        bubbles: true
190      })),
191      createInputEvent: overrides.createInputEvent || ((data) =>
192        new globalScope.InputEvent("input", {
193          bubbles: true,
194          cancelable: true,
195          data,
196          inputType: "insertText"
197        })
198      ),
199      document: documentRef,
200      getComputedStyle: overrides.getComputedStyle || ((element) =>
201        typeof globalScope.getComputedStyle === "function" ? globalScope.getComputedStyle(element) : null
202      ),
203      getLocationHref: overrides.getLocationHref || (() => locationRef?.href || ""),
204      now: overrides.now || (() => Date.now()),
205      sleep: overrides.sleep || ((ms) =>
206        new Promise((resolve) => globalScope.setTimeout(resolve, Math.max(0, Number(ms) || 0)))
207      )
208    };
209  }
210
211  function getPlatformAdapter(platform) {
212    const normalized = trimToNull(platform);
213
214    if (!normalized) {
215      return null;
216    }
217
218    const adapter = PLATFORM_ADAPTERS[normalized];
219    return adapter ? {
220      ...adapter,
221      platform: normalized
222    } : null;
223  }
224
225  function listPlatformAdapters() {
226    return Object.keys(PLATFORM_ADAPTERS).sort().map((platform) => getPlatformAdapter(platform));
227  }
228
229  function elementDisabled(element) {
230    if (!isElementLike(element)) {
231      return true;
232    }
233
234    if (typeof element.disabled === "boolean") {
235      return element.disabled;
236    }
237
238    return element.getAttribute?.("aria-disabled") === "true";
239  }
240
241  function isElementVisible(env, element, options = {}) {
242    if (!isElementLike(element)) {
243      return false;
244    }
245
246    if (options.allowHidden === true) {
247      return true;
248    }
249
250    const style = env.getComputedStyle(element);
251    if (style) {
252      if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") === 0) {
253        return false;
254      }
255    }
256
257    const rect = element.getBoundingClientRect();
258    return rect.width > 0 && rect.height > 0;
259  }
260
261  function queryAll(env, selector) {
262    if (!env.document || typeof selector !== "string" || !selector) {
263      return [];
264    }
265
266    try {
267      const matches = env.document.querySelectorAll(selector);
268      return Array.isArray(matches) ? matches : Array.from(matches || []);
269    } catch (_) {
270      return [];
271    }
272  }
273
274  function queryFirst(env, selectors, options = {}) {
275    for (const selector of selectors) {
276      for (const element of queryAll(env, selector)) {
277        if (isElementVisible(env, element, options)) {
278          return element;
279        }
280      }
281    }
282
283    return null;
284  }
285
286  function readComposerText(element) {
287    if (isInputLike(element)) {
288      return typeof element.value === "string" ? element.value : "";
289    }
290
291    if (isContentEditableLike(element)) {
292      return typeof element.textContent === "string" ? element.textContent : "";
293    }
294
295    return "";
296  }
297
298  function dispatchInputEvents(env, element) {
299    if (!isElementLike(element) || typeof element.dispatchEvent !== "function") {
300      return;
301    }
302
303    element.dispatchEvent(env.createInputEvent(readComposerText(element)));
304    element.dispatchEvent(env.createChangeEvent());
305  }
306
307  function setNativeValue(element, value) {
308    if (!isInputLike(element)) {
309      return;
310    }
311
312    const prototype = String(element.tagName || "").toUpperCase() === "TEXTAREA"
313      ? globalScope.HTMLTextAreaElement?.prototype
314      : globalScope.HTMLInputElement?.prototype;
315    const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
316
317    if (descriptor?.set) {
318      descriptor.set.call(element, value);
319      return;
320    }
321
322    element.value = value;
323  }
324
325  function setComposerText(env, element, text) {
326    if (isInputLike(element)) {
327      element.focus?.();
328      setNativeValue(element, text);
329      dispatchInputEvents(env, element);
330      return;
331    }
332
333    if (!isContentEditableLike(element)) {
334      throw new Error("page composer is not editable");
335    }
336
337    element.focus?.();
338    try {
339      if (typeof env.document?.execCommand === "function") {
340        env.document.execCommand("selectAll", false);
341        env.document.execCommand("insertText", false, text);
342      } else {
343        element.textContent = text;
344      }
345    } catch (_) {
346      element.textContent = text;
347    }
348    dispatchInputEvents(env, element);
349  }
350
351  async function waitForValue(env, resolveValue, options = {}) {
352    const timeoutMs = toPositiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS);
353    const intervalMs = toPositiveInteger(options.intervalMs, DEFAULT_POLL_INTERVAL_MS);
354    const startedAt = env.now();
355    let lastError = null;
356
357    while (env.now() - startedAt <= timeoutMs) {
358      try {
359        const value = await resolveValue();
360
361        if (value) {
362          return value;
363        }
364      } catch (error) {
365        lastError = error;
366      }
367
368      if (env.now() - startedAt >= timeoutMs) {
369        break;
370      }
371
372      await env.sleep(intervalMs);
373    }
374
375    if (typeof options.buildError === "function") {
376      throw options.buildError(lastError);
377    }
378
379    throw normalizeDeliveryError(lastError, {
380      timeout_ms: timeoutMs
381    });
382  }
383
384  function matchesExpectedHost(expectedHosts, currentHref) {
385    const href = trimToNull(currentHref);
386    if (!href || expectedHosts.length === 0) {
387      return true;
388    }
389
390    try {
391      const parsed = new URL(href, "https://delivery.invalid/");
392      return expectedHosts.includes(parsed.hostname);
393    } catch (_) {
394      return true;
395    }
396  }
397
398  function buildAttemptContext(env, adapter, command, metadata = {}) {
399    return {
400      command,
401      platform: adapter.platform,
402      ready_state: trimToNull(env.document?.readyState) || null,
403      url: trimToNull(env.getLocationHref()) || null,
404      ...metadata
405    };
406  }
407
408  function resolveAttemptSettings(command) {
409    const totalTimeoutMs = toPositiveInteger(command?.timeoutMs, DEFAULT_TIMEOUT_MS);
410    const attempts = Math.min(3, Math.max(1, toPositiveInteger(command?.retryAttempts, DEFAULT_RETRY_ATTEMPTS)));
411    const intervalMs = Math.max(10, toPositiveInteger(command?.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS));
412    const attemptBudgetMs = Math.max(150, Math.floor(totalTimeoutMs / attempts));
413
414    return {
415      attemptBudgetMs,
416      attempts,
417      confirmTimeoutMs: Math.max(50, Math.min(6_000, Math.floor(attemptBudgetMs * 0.3))),
418      intervalMs,
419      pageReadyTimeoutMs: Math.max(50, Math.min(12_000, Math.floor(attemptBudgetMs * 0.35))),
420      retryDelayMs: Math.max(25, toPositiveInteger(command?.retryDelayMs, DEFAULT_RETRY_DELAY_MS)),
421      selectorTimeoutMs: Math.max(50, Math.min(10_000, Math.floor(attemptBudgetMs * 0.35)))
422    };
423  }
424
425  function findComposer(env, adapter) {
426    return queryFirst(env, adapter.composerSelectors);
427  }
428
429  function findSendButton(env, adapter, options = {}) {
430    const candidate = queryFirst(env, adapter.sendButtonSelectors, {
431      allowHidden: options.allowHidden === true
432    });
433
434    if (candidate == null) {
435      return null;
436    }
437
438    if (options.includeDisabled === true || !elementDisabled(candidate)) {
439      return candidate;
440    }
441
442    return null;
443  }
444
445  function findSendingIndicator(env, adapter) {
446    return queryFirst(env, adapter.sendingSelectors, {
447      allowHidden: true
448    });
449  }
450
451  async function ensurePageReady(env, adapter, command, settings, attempt, attempts) {
452    const expectedHosts = Array.isArray(adapter.pageHosts) ? adapter.pageHosts : [];
453    if (!matchesExpectedHost(expectedHosts, env.getLocationHref())) {
454      throw new DeliveryError(
455        "page_context_mismatch",
456        `${adapter.label} page host does not match delivery target`,
457        buildAttemptContext(env, adapter, command, {
458          attempt,
459          attempts,
460          expected_hosts: expectedHosts
461        })
462      );
463    }
464
465    return await waitForValue(
466      env,
467      () => {
468        if (!env.document?.body) {
469          return null;
470        }
471
472        const readyState = String(env.document.readyState || "").toLowerCase();
473        if (readyState === "loading") {
474          return null;
475        }
476
477        const marker = queryFirst(env, adapter.readinessSelectors, {
478          allowHidden: true
479        });
480        if (!marker) {
481          return null;
482        }
483
484        return {
485          readyState
486        };
487      },
488      {
489        buildError: () => new DeliveryError(
490          "page_not_ready",
491          `${adapter.label} page is not ready for ${command}`,
492          buildAttemptContext(env, adapter, command, {
493            attempt,
494            attempts,
495            readiness_selectors: adapter.readinessSelectors
496          })
497        ),
498        intervalMs: settings.intervalMs,
499        timeoutMs: settings.pageReadyTimeoutMs
500      }
501    );
502  }
503
504  async function resolveSelector(env, adapter, command, selectorKind, selectors, settings, attempt, attempts, options = {}) {
505    return await waitForValue(
506      env,
507      () => queryFirst(env, selectors, options),
508      {
509        buildError: () => new DeliveryError(
510          "selector_missing",
511          `${adapter.label} ${selectorKind} selector was not found`,
512          buildAttemptContext(env, adapter, command, {
513            attempt,
514            attempts,
515            selector_kind: selectorKind,
516            selectors
517          })
518        ),
519        intervalMs: settings.intervalMs,
520        timeoutMs: settings.selectorTimeoutMs
521      }
522    );
523  }
524
525  async function confirmInjection(env, adapter, command, text, settings, attempt, attempts) {
526    const expectedText = normalizeText(text);
527
528    return await waitForValue(
529      env,
530      () => {
531        const composer = findComposer(env, adapter);
532        if (!composer) {
533          return null;
534        }
535
536        const currentText = normalizeText(readComposerText(composer));
537        if (!currentText || !currentText.includes(expectedText)) {
538          return null;
539        }
540
541        return {
542          confirmation: "composer_text_match"
543        };
544      },
545      {
546        buildError: () => new DeliveryError(
547          "inject_not_confirmed",
548          `${adapter.label} composer did not retain injected text`,
549          buildAttemptContext(env, adapter, command, {
550            attempt,
551            attempts,
552            text_length: text.length
553          })
554        ),
555        intervalMs: settings.intervalMs,
556        timeoutMs: settings.confirmTimeoutMs
557      }
558    );
559  }
560
561  async function confirmSend(env, adapter, command, beforeText, settings, attempt, attempts) {
562    return await waitForValue(
563      env,
564      () => {
565        if (findSendingIndicator(env, adapter)) {
566          return {
567            confirmation: "sending_indicator"
568          };
569        }
570
571        const button = findSendButton(env, adapter, {
572          allowHidden: true,
573          includeDisabled: true
574        });
575        if (button && elementDisabled(button)) {
576          return {
577            confirmation: "send_button_disabled"
578          };
579        }
580
581        const composer = findComposer(env, adapter);
582        if (composer) {
583          const afterText = normalizeText(readComposerText(composer));
584          if (beforeText && afterText !== beforeText) {
585            return {
586              confirmation: afterText ? "composer_changed" : "composer_cleared"
587            };
588          }
589        }
590
591        return null;
592      },
593      {
594        buildError: () => new DeliveryError(
595          "send_not_confirmed",
596          `${adapter.label} send click did not produce a confirmed state transition`,
597          buildAttemptContext(env, adapter, command, {
598            attempt,
599            attempts
600          })
601        ),
602        intervalMs: settings.intervalMs,
603        timeoutMs: settings.confirmTimeoutMs
604      }
605    );
606  }
607
608  async function executeInject(env, adapter, command, settings, attempt, attempts) {
609    const text = trimToNull(command?.text);
610
611    if (!text) {
612      throw new DeliveryError("invalid_payload", "message text is required");
613    }
614
615    await ensurePageReady(env, adapter, "inject_message", settings, attempt, attempts);
616    const composer = await resolveSelector(
617      env,
618      adapter,
619      "inject_message",
620      "composer",
621      adapter.composerSelectors,
622      settings,
623      attempt,
624      attempts
625    );
626    setComposerText(env, composer, text);
627    const confirmation = await confirmInjection(env, adapter, "inject_message", text, settings, attempt, attempts);
628
629    return {
630      ok: true,
631      details: {
632        attempt,
633        attempts,
634        confirmed_by: confirmation.confirmation
635      }
636    };
637  }
638
639  async function executeSend(env, adapter, command, settings, attempt, attempts) {
640    await ensurePageReady(env, adapter, "send_message", settings, attempt, attempts);
641    const composer = await resolveSelector(
642      env,
643      adapter,
644      "send_message",
645      "composer",
646      adapter.composerSelectors,
647      settings,
648      attempt,
649      attempts
650    );
651    const button = await resolveSelector(
652      env,
653      adapter,
654      "send_message",
655      "send_button",
656      adapter.sendButtonSelectors,
657      settings,
658      attempt,
659      attempts
660    );
661
662    if (elementDisabled(button)) {
663      throw new DeliveryError(
664        "send_unavailable",
665        `${adapter.label} send button is disabled before click`,
666        buildAttemptContext(env, adapter, "send_message", {
667          attempt,
668          attempts
669        })
670      );
671    }
672
673    const beforeText = normalizeText(readComposerText(composer));
674    button.click?.();
675    const confirmation = await confirmSend(env, adapter, "send_message", beforeText, settings, attempt, attempts);
676
677    return {
678      ok: true,
679      details: {
680        attempt,
681        attempts,
682        confirmed_by: confirmation.confirmation
683      }
684    };
685  }
686
687  async function executeCommandAttempt(env, adapter, command, settings, attempt, attempts) {
688    switch (command.command) {
689      case "inject_message":
690        return await executeInject(env, adapter, command, settings, attempt, attempts);
691      case "send_message":
692        return await executeSend(env, adapter, command, settings, attempt, attempts);
693      default:
694        throw new DeliveryError("invalid_command", `unsupported delivery command: ${command.command}`);
695    }
696  }
697
698  function shouldRetry(error) {
699    const code = trimToNull(error?.code) || "";
700    return !["invalid_command", "invalid_payload", "page_context_mismatch"].includes(code);
701  }
702
703  function buildSuccessResult(adapter, commandName, result) {
704    return {
705      ok: true,
706      platform: adapter.platform,
707      command: commandName,
708      remoteHandle: trimToNull(result?.remoteHandle) || null,
709      details: {
710        adapter: adapter.label,
711        ...(isRecord(result?.details) ? result.details : {})
712      }
713    };
714  }
715
716  function buildFailureResult(adapter, commandName, error) {
717    const normalized = normalizeDeliveryError(error, {
718      command: commandName,
719      platform: adapter?.platform || null
720    });
721
722    return {
723      ok: false,
724      platform: adapter?.platform || null,
725      command: commandName,
726      code: normalized.code,
727      reason: formatFailureReason(normalized),
728      details: normalized.details
729    };
730  }
731
732  function createDeliveryRuntime(options = {}) {
733    const env = createBrowserEnv(options.env || {});
734
735    return {
736      async handleCommand(command = {}) {
737        const commandName = trimToNull(command?.command);
738        if (!commandName) {
739          return {
740            ok: false,
741            command: null,
742            platform: trimToNull(command?.platform),
743            code: "invalid_command",
744            reason: "delivery.invalid_command: delivery command is required",
745            details: {}
746          };
747        }
748
749        const adapter = getPlatformAdapter(command?.platform);
750        if (!adapter) {
751          return {
752            ok: false,
753            command: commandName,
754            platform: trimToNull(command?.platform),
755            code: "unsupported_platform",
756            reason: `delivery.unsupported_platform: unsupported delivery platform: ${trimToNull(command?.platform) || "-"}`,
757            details: {}
758          };
759        }
760
761        const settings = resolveAttemptSettings(command);
762        let lastError = null;
763
764        for (let attempt = 1; attempt <= settings.attempts; attempt += 1) {
765          try {
766            const result = await executeCommandAttempt(env, adapter, command, settings, attempt, settings.attempts);
767            return buildSuccessResult(adapter, commandName, result);
768          } catch (error) {
769            lastError = normalizeDeliveryError(error, {
770              attempt,
771              attempts: settings.attempts,
772              command: commandName,
773              platform: adapter.platform
774            });
775
776            if (attempt < settings.attempts && shouldRetry(lastError)) {
777              await env.sleep(settings.retryDelayMs);
778              continue;
779            }
780          }
781        }
782
783        return buildFailureResult(adapter, commandName, lastError);
784      }
785    };
786  }
787
788  const api = {
789    createDeliveryRuntime,
790    getPlatformAdapter,
791    listPlatformAdapters
792  };
793
794  if (typeof module !== "undefined" && module.exports) {
795    module.exports = api;
796  }
797
798  globalScope.BAADeliveryAdapters = api;
799})(typeof globalThis !== "undefined" ? globalThis : this);